@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 +4 -4
- package/README.zh-CN.md +4 -4
- package/bin/revela.ts +2 -0
- package/lib/design/archive.ts +167 -0
- package/lib/design/designs.ts +224 -4
- package/lib/refine/server.ts +141 -62
- package/lib/runtime/index.ts +49 -0
- package/package.json +1 -1
- package/plugins/revela/.mcp.json +1 -1
- package/plugins/revela/mcp/revela-server.ts +46 -0
- package/plugins/revela/skills/revela-design/SKILL.md +15 -2
- package/plugins/revela/skills/revela-spec/SKILL.md +9 -0
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**English** | [中文](README.zh-CN.md)
|
|
4
4
|
|
|
5
|
-
[](https://www.npmjs.com/package/@cyber-dash-tech/revela) [](LICENSE) [](https://www.npmjs.com/package/@cyber-dash-tech/revela) [](LICENSE) [](tests/) [](https://opencode.ai) [](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.
|
|
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.
|
|
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.
|
|
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
|
-
[](https://www.npmjs.com/package/@cyber-dash-tech/revela) [](LICENSE) [](https://www.npmjs.com/package/@cyber-dash-tech/revela) [](LICENSE) [](tests/) [](https://opencode.ai) [](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.
|
|
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.
|
|
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.
|
|
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
|
+
}
|
package/lib/design/designs.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
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
|
}
|