@miphamai/cli 0.81.7 → 0.81.8
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 +1 -1
- package/package.json +1 -1
- package/src/artifacts/manifest.ts +90 -34
- package/src/artifacts/paths.ts +19 -0
- package/src/artifacts/server.ts +48 -8
- package/src/config/keys-manager.ts +7 -8
- package/src/config/preferences.ts +5 -2
- package/src/core/cron-poller.ts +30 -6
- package/src/core/permission-rules.ts +140 -4
- package/src/core/session-log.ts +44 -1
- package/src/core/session-store.ts +11 -1
- package/src/daemon/session-worker.ts +15 -0
- package/src/index.tsx +3 -2
- package/src/plugin/plugin-manager.ts +17 -6
- package/src/providers/anthropic.ts +26 -2
- package/src/providers/openai-compat.ts +14 -1
- package/src/security/path.ts +6 -1
- package/src/shared/atomic-write.ts +28 -5
- package/src/shared/package-info.ts +1 -1
- package/src/shared/types.ts +9 -0
- package/src/tools/artifact/artifact.ts +14 -4
- package/src/tools/exec/bash.ts +40 -18
- package/src/tools/exec/git.ts +7 -2
- package/src/tools/file/grep.ts +37 -13
- package/src/tools/file/read.ts +151 -45
- package/src/tools/scheduling/cron.ts +34 -5
- package/src/tools/system/config.ts +6 -2
- package/src/ui/commands.ts +27 -11
- package/src/artifacts/versioning.ts +0 -127
package/src/tools/file/read.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync, fstatSync, closeSync, constants } from 'node:fs'
|
|
1
|
+
import { readFileSync, readSync, fstatSync, closeSync, constants } from 'node:fs'
|
|
2
2
|
import type { ToolDefinition, CredentialMaskingConfig } from '../../shared/index.ts'
|
|
3
3
|
import { resolveSafe } from '../../security/path'
|
|
4
4
|
import { openNoFollow, isSymlinkLoop } from '../../security/fd'
|
|
@@ -6,6 +6,115 @@ import type { Service } from '../../vajra'
|
|
|
6
6
|
import { toolKey } from '../seam'
|
|
7
7
|
import { withValidation } from '../validation'
|
|
8
8
|
|
|
9
|
+
/** Lines returned when the caller passes no `limit`. */
|
|
10
|
+
const DEFAULT_LIMIT = 2000
|
|
11
|
+
|
|
12
|
+
/** Bytes per syscall while scanning for newlines. */
|
|
13
|
+
const CHUNK_BYTES = 64 * 1024
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* How far a single Read may scan to locate the requested lines. Lines are
|
|
17
|
+
* addressed by index, so finding line N means scanning everything before it;
|
|
18
|
+
* this is what keeps that scan from turning a request for two lines of a 2 GB
|
|
19
|
+
* file into a 2 GB read.
|
|
20
|
+
*/
|
|
21
|
+
const MAX_SCAN_BYTES = 50_000_000
|
|
22
|
+
|
|
23
|
+
/** Render a line window the way the tool reports it: ` 123\ttext`. */
|
|
24
|
+
function formatLines(lines: string[], offset: number): string {
|
|
25
|
+
return lines.map((l, i) => `${String(offset + i + 1).padStart(6, ' ')}\t${l}`).join('\n')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Lines `[offset, offset + limit)` of an already-decoded string.
|
|
30
|
+
*
|
|
31
|
+
* Scans newlines instead of `content.split('\n')`, which materializes one JS
|
|
32
|
+
* string for *every* line of the file in order to return `limit` of them. On a
|
|
33
|
+
* 20 MB source, returning 2000 lines that way cost +34 MB of heap for 60-char
|
|
34
|
+
* lines and +100 MB for 3-char lines (333k vs 10M lines — it tracks line count,
|
|
35
|
+
* not bytes); scanning costs ~0 on top of the string it is handed.
|
|
36
|
+
*/
|
|
37
|
+
function windowFromString(content: string, offset: number, limit: number): string[] {
|
|
38
|
+
const out: string[] = []
|
|
39
|
+
let lineNo = 0
|
|
40
|
+
let start = 0
|
|
41
|
+
while (out.length < limit) {
|
|
42
|
+
const nl = content.indexOf('\n', start)
|
|
43
|
+
const end = nl === -1 ? content.length : nl
|
|
44
|
+
if (lineNo >= offset) out.push(content.slice(start, end))
|
|
45
|
+
lineNo++
|
|
46
|
+
if (nl === -1) break
|
|
47
|
+
start = nl + 1
|
|
48
|
+
}
|
|
49
|
+
return out
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Lines `[offset, offset + limit)` read from `fd` — only those lines are ever
|
|
54
|
+
* materialized, so cost tracks the window rather than the file: a 2000-line
|
|
55
|
+
* window of a 20 MB file leaves RSS at the process baseline (~38 MB), where
|
|
56
|
+
* reading the file whole and slicing it cost 142–255 MB.
|
|
57
|
+
*
|
|
58
|
+
* Two passes over the same bytes: the first only *looks* for newline bytes to
|
|
59
|
+
* learn where the window starts and ends, the second reads exactly that byte
|
|
60
|
+
* range and decodes it once. Scanning raw bytes is safe because `0x0a` never
|
|
61
|
+
* occurs inside a multi-byte UTF-8 sequence (continuation bytes are >= 0x80),
|
|
62
|
+
* and the decoded range starts and ends on a newline — so neither pass can split
|
|
63
|
+
* a character. Returns null when the window cannot be located within
|
|
64
|
+
* `MAX_SCAN_BYTES`.
|
|
65
|
+
*/
|
|
66
|
+
function windowFromFd(fd: number, offset: number, limit: number): string[] | null {
|
|
67
|
+
if (limit <= 0) return []
|
|
68
|
+
|
|
69
|
+
const buf = Buffer.allocUnsafe(CHUNK_BYTES)
|
|
70
|
+
let scanned = 0 // bytes consumed by the scan
|
|
71
|
+
let lineNo = 0
|
|
72
|
+
let windowStart = offset <= 0 ? 0 : -1
|
|
73
|
+
let windowEnd = -1
|
|
74
|
+
let eof = false
|
|
75
|
+
|
|
76
|
+
while (windowEnd === -1 && scanned < MAX_SCAN_BYTES) {
|
|
77
|
+
const n = readSync(fd, buf, 0, buf.length, scanned)
|
|
78
|
+
if (n <= 0) {
|
|
79
|
+
eof = true
|
|
80
|
+
break
|
|
81
|
+
}
|
|
82
|
+
const lastNl = buf.lastIndexOf(0x0a, n - 1)
|
|
83
|
+
if (lastNl !== -1) {
|
|
84
|
+
let from = 0
|
|
85
|
+
for (;;) {
|
|
86
|
+
const nl = buf.indexOf(0x0a, from)
|
|
87
|
+
if (nl === -1 || nl > lastNl) break
|
|
88
|
+
lineNo++ // line `lineNo - 1` ends at this newline
|
|
89
|
+
if (lineNo === offset) windowStart = scanned + nl + 1
|
|
90
|
+
if (lineNo === offset + limit) {
|
|
91
|
+
windowEnd = scanned + nl
|
|
92
|
+
break
|
|
93
|
+
}
|
|
94
|
+
from = nl + 1
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
scanned += n
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (windowEnd === -1) {
|
|
101
|
+
// Either the file ended (the window is everything that is left) or the scan
|
|
102
|
+
// budget ran out before the window's last line was reached.
|
|
103
|
+
if (!eof) return null
|
|
104
|
+
windowEnd = scanned
|
|
105
|
+
}
|
|
106
|
+
if (windowStart === -1) return [] // `offset` is past the end of the file
|
|
107
|
+
|
|
108
|
+
const out = Buffer.allocUnsafe(windowEnd - windowStart)
|
|
109
|
+
let got = 0
|
|
110
|
+
while (got < out.length) {
|
|
111
|
+
const n = readSync(fd, out, got, out.length - got, windowStart + got)
|
|
112
|
+
if (n <= 0) break
|
|
113
|
+
got += n
|
|
114
|
+
}
|
|
115
|
+
return out.toString('utf-8', 0, got).split('\n')
|
|
116
|
+
}
|
|
117
|
+
|
|
9
118
|
export function createReadTool(credentialConfig?: CredentialMaskingConfig): ToolDefinition {
|
|
10
119
|
return {
|
|
11
120
|
name: 'Read',
|
|
@@ -40,59 +149,56 @@ export function createReadTool(credentialConfig?: CredentialMaskingConfig): Tool
|
|
|
40
149
|
throw err
|
|
41
150
|
}
|
|
42
151
|
|
|
43
|
-
let content: string
|
|
44
152
|
try {
|
|
45
|
-
|
|
46
|
-
if (stat.isDirectory()) {
|
|
153
|
+
if (fstatSync(fd).isDirectory()) {
|
|
47
154
|
return { success: false, content: '', error: `Path is a directory: ${filePath}` }
|
|
48
155
|
}
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
156
|
+
|
|
157
|
+
const offset = (params.offset as number) || 0
|
|
158
|
+
const limit = (params.limit as number) || DEFAULT_LIMIT
|
|
159
|
+
|
|
160
|
+
// ── Credential masking ──
|
|
161
|
+
// The rule matches on path alone, so this is decided before any read: a
|
|
162
|
+
// masked file needs its whole content (the mask is content-shaped), while
|
|
163
|
+
// every other file is served straight from the fd. Reading the file
|
|
164
|
+
// *first* is what used to make `offset`/`limit` useless on large files —
|
|
165
|
+
// the size check ran before the window was ever applied.
|
|
166
|
+
let result: string | null = null
|
|
167
|
+
if (credentialConfig) {
|
|
168
|
+
const { matchCredentialFile, maskContent, CREDENTIAL_SENTINEL } =
|
|
169
|
+
await import('../../core/credential-masker')
|
|
170
|
+
const rule = matchCredentialFile(filePath, credentialConfig)
|
|
171
|
+
if (rule) {
|
|
172
|
+
const masked = maskContent(readFileSync(fd, 'utf-8'), rule)
|
|
173
|
+
// Full-file mask: return the sentinel immediately (offset/limit don't apply)
|
|
174
|
+
result =
|
|
175
|
+
masked === CREDENTIAL_SENTINEL
|
|
176
|
+
? masked
|
|
177
|
+
: formatLines(windowFromString(masked, offset, limit), offset)
|
|
56
178
|
}
|
|
57
179
|
}
|
|
58
|
-
content = readFileSync(fd, 'utf-8')
|
|
59
|
-
} finally {
|
|
60
|
-
closeSync(fd)
|
|
61
|
-
}
|
|
62
180
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if (rule) {
|
|
75
|
-
const masked = maskContent(content, rule)
|
|
76
|
-
// Full-file mask: return sentinel immediately (offset/limit don't apply)
|
|
77
|
-
if (masked === CREDENTIAL_SENTINEL) {
|
|
78
|
-
return { success: true, content: masked }
|
|
181
|
+
if (result === null) {
|
|
182
|
+
const lines = windowFromFd(fd, offset, limit)
|
|
183
|
+
if (lines === null) {
|
|
184
|
+
return {
|
|
185
|
+
success: false,
|
|
186
|
+
content: '',
|
|
187
|
+
error:
|
|
188
|
+
`File too large: scanned ${MAX_SCAN_BYTES / 1e6} MB without reaching the end of lines ` +
|
|
189
|
+
`${offset}–${offset + limit - 1}. Narrow the range (smaller offset or limit), or use a ` +
|
|
190
|
+
`shell tool to slice the file.`,
|
|
191
|
+
}
|
|
79
192
|
}
|
|
80
|
-
|
|
81
|
-
const maskedLines = masked.split('\n')
|
|
82
|
-
const maskedSlice = maskedLines.slice(offset, offset + limit)
|
|
83
|
-
const maskedResult = maskedSlice
|
|
84
|
-
.map((l, i) => `${String(offset + i + 1).padStart(6, ' ')}\t${l}`)
|
|
85
|
-
.join('\n')
|
|
86
|
-
return { success: true, content: maskedResult }
|
|
193
|
+
result = formatLines(lines, offset)
|
|
87
194
|
}
|
|
88
|
-
}
|
|
89
195
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
196
|
+
// ── Read tracking: mark file as read for Write tool safety ──
|
|
197
|
+
ctx.readFiles?.add(filePath)
|
|
198
|
+
return { success: true, content: result }
|
|
199
|
+
} finally {
|
|
200
|
+
closeSync(fd)
|
|
201
|
+
}
|
|
96
202
|
},
|
|
97
203
|
}
|
|
98
204
|
}
|
|
@@ -26,14 +26,31 @@ export interface CronJob {
|
|
|
26
26
|
createdAt: string
|
|
27
27
|
nextFire: string
|
|
28
28
|
lastFired: string | null
|
|
29
|
+
/**
|
|
30
|
+
* The directory this job belongs to. The store is global (`~/.mipham/cron/`)
|
|
31
|
+
* and every session's poller reads all of it, so without this a job created in
|
|
32
|
+
* project A gets executed by whichever session in project B happens to be
|
|
33
|
+
* running — its prompt expands against the wrong codebase.
|
|
34
|
+
*
|
|
35
|
+
* Optional because files written before this field existed genuinely lack it;
|
|
36
|
+
* the poller treats a missing `cwd` as "any directory" so those keep firing.
|
|
37
|
+
*/
|
|
38
|
+
cwd?: string
|
|
39
|
+
/** Session that created the job. Informational — the poller keys on `cwd`. */
|
|
40
|
+
sessionId?: string
|
|
29
41
|
}
|
|
30
42
|
|
|
31
43
|
function jobPath(id: string): string {
|
|
32
44
|
return join(CRON_DIR, `${id}.json`)
|
|
33
45
|
}
|
|
34
46
|
|
|
35
|
-
|
|
36
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Job id includes `cwd`: keyed on `cron:prompt` alone, creating the same schedule
|
|
49
|
+
* in two directories wrote to the same file and the second silently replaced the
|
|
50
|
+
* first.
|
|
51
|
+
*/
|
|
52
|
+
function generateId(cron: string, prompt: string, cwd: string): string {
|
|
53
|
+
return createHash('sha256').update(`${cwd}:${cron}:${prompt}`).digest('hex').slice(0, 12)
|
|
37
54
|
}
|
|
38
55
|
|
|
39
56
|
/**
|
|
@@ -101,11 +118,11 @@ export const cronCreateTool: ToolDefinition = {
|
|
|
101
118
|
},
|
|
102
119
|
required: ['cron', 'prompt'],
|
|
103
120
|
},
|
|
104
|
-
async execute(params,
|
|
121
|
+
async execute(params, ctx) {
|
|
105
122
|
const cron = params.cron as string
|
|
106
123
|
const prompt = params.prompt as string
|
|
107
124
|
const recurring = params.recurring !== false
|
|
108
|
-
const id = generateId(cron, prompt)
|
|
125
|
+
const id = generateId(cron, prompt, ctx.cwd)
|
|
109
126
|
|
|
110
127
|
const now = new Date()
|
|
111
128
|
const job: CronJob = {
|
|
@@ -116,6 +133,8 @@ export const cronCreateTool: ToolDefinition = {
|
|
|
116
133
|
createdAt: now.toISOString(),
|
|
117
134
|
nextFire: computeNextFire(cron, now),
|
|
118
135
|
lastFired: null,
|
|
136
|
+
cwd: ctx.cwd,
|
|
137
|
+
sessionId: ctx.sessionId,
|
|
119
138
|
}
|
|
120
139
|
|
|
121
140
|
writeJob(job)
|
|
@@ -127,6 +146,7 @@ export const cronCreateTool: ToolDefinition = {
|
|
|
127
146
|
`Created ${type} cron job.\n` +
|
|
128
147
|
`ID: ${id}\n` +
|
|
129
148
|
`Schedule: ${cron}\n` +
|
|
149
|
+
`Scoped to: ${ctx.cwd}\n` +
|
|
130
150
|
`Prompt: "${prompt.slice(0, 80)}${prompt.length > 80 ? '...' : ''}"`,
|
|
131
151
|
}
|
|
132
152
|
},
|
|
@@ -182,11 +202,20 @@ export const cronListTool: ToolDefinition = {
|
|
|
182
202
|
const lines = [`── Scheduled Cron Jobs (${jobs.length}) ──`, '']
|
|
183
203
|
for (const j of jobs) {
|
|
184
204
|
const type = j.recurring ? 'recurring' : 'one-shot'
|
|
185
|
-
|
|
205
|
+
// 目录要看得见:任务被限定在建立它的那个目录,从别的项目 `/schedule` 列出来时
|
|
206
|
+
// 用户得能看出它为什么不在自己这里跑。**没有 cwd 的旧文件恰恰相反** —— 它在任何
|
|
207
|
+
// 目录都会执行,而这里是唯一能看见那件事的地方,所以必须显式标出来(留空等于
|
|
208
|
+
// 把「无归属」显示成「和别的任务一样」)。
|
|
209
|
+
lines.push(`${j.id} ${j.cron} ${type} ${j.cwd ? `[${j.cwd}]` : '[无归属]'}`)
|
|
186
210
|
lines.push(` ${j.prompt.slice(0, 100)}`)
|
|
187
211
|
lines.push('')
|
|
188
212
|
}
|
|
189
213
|
|
|
214
|
+
if (jobs.some((j) => !j.cwd)) {
|
|
215
|
+
lines.push('⚠️ [无归属] 是加 cwd 字段之前写的任务:没有目录可判,任何项目里都会执行。')
|
|
216
|
+
lines.push(' 消除办法:在目标目录里用 CronCreate 重建,或让 Mipham 用 CronDelete 删掉。')
|
|
217
|
+
}
|
|
218
|
+
|
|
190
219
|
return { success: true, content: lines.join('\n') }
|
|
191
220
|
},
|
|
192
221
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { readFileSync,
|
|
1
|
+
import { readFileSync, existsSync, mkdirSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import { homedir } from 'node:os'
|
|
4
4
|
import { parse as parseYaml, stringify } from 'yaml'
|
|
5
|
+
import { atomicWriteFileSync } from '../../shared/atomic-write'
|
|
5
6
|
import type { ToolDefinition } from '../../shared/index.ts'
|
|
6
7
|
|
|
7
8
|
const MIPHAM_HOME = join(homedir(), '.mipham')
|
|
@@ -60,7 +61,10 @@ export const configTool: ToolDefinition = {
|
|
|
60
61
|
obj = obj[k] as Record<string, unknown>
|
|
61
62
|
}
|
|
62
63
|
obj[keys[keys.length - 1]!] = params.value
|
|
63
|
-
writeFileSync
|
|
64
|
+
// 原子写 + 0o600:这是整份 read-modify-write,裸 writeFileSync 原地截断 ——
|
|
65
|
+
// 崩在写中途就留下半截 YAML,而权限由 umask 决定(典型 0644),比同一份配置的
|
|
66
|
+
// 另一个写者 saveProviderApiKey(loader.ts,0600 原子写)更松。
|
|
67
|
+
atomicWriteFileSync(USER_CONFIG, stringify(config), { mode: 0o600 })
|
|
64
68
|
return { success: true, content: `Set ${key} = ${params.value}` }
|
|
65
69
|
}
|
|
66
70
|
|
package/src/ui/commands.ts
CHANGED
|
@@ -5191,10 +5191,23 @@ const artifactOpenCmd: CommandHandler = async (ctx, args) => {
|
|
|
5191
5191
|
return { content: 'Usage: /artifact open <name>\nExample: /artifact open dashboard' }
|
|
5192
5192
|
}
|
|
5193
5193
|
|
|
5194
|
-
const
|
|
5195
|
-
const
|
|
5196
|
-
const
|
|
5197
|
-
|
|
5194
|
+
const { readManifest } = await import('../artifacts/manifest')
|
|
5195
|
+
const { artifactsRoot } = await import('../artifacts/paths')
|
|
5196
|
+
const manifest = readManifest(artifactsRoot(process.cwd()))
|
|
5197
|
+
|
|
5198
|
+
// 先找本会话,再退到任意会话:文件在磁盘上跨会话留存,而 `open` 是用户手敲的。
|
|
5199
|
+
// 原先写死 `session-1` + 端口 9876,工具回报的却是真会话 id 和**实际**端口
|
|
5200
|
+
// (服务端遇到端口占用会自增),所以这条命令从构造上就打不开任何东西。
|
|
5201
|
+
const entry =
|
|
5202
|
+
manifest.artifacts.find((a) => a.name === name && a.sessionId === ctx.sessionId) ??
|
|
5203
|
+
manifest.artifacts.find((a) => a.name === name)
|
|
5204
|
+
|
|
5205
|
+
if (!entry) {
|
|
5206
|
+
return { content: `✗ No artifact named "${name}". Run /artifact list to see them.` }
|
|
5207
|
+
}
|
|
5208
|
+
|
|
5209
|
+
// 用工具落盘时记下的 URL —— 那就是它印给用户的同一个坐标,两边不该各算一次。
|
|
5210
|
+
const url = entry.url
|
|
5198
5211
|
|
|
5199
5212
|
try {
|
|
5200
5213
|
await openBrowser(url)
|
|
@@ -5204,13 +5217,14 @@ const artifactOpenCmd: CommandHandler = async (ctx, args) => {
|
|
|
5204
5217
|
}
|
|
5205
5218
|
}
|
|
5206
5219
|
|
|
5207
|
-
const artifactListCmd: CommandHandler = async (
|
|
5208
|
-
const { getSessionArtifacts } = await import('../artifacts/manifest')
|
|
5209
|
-
const {
|
|
5210
|
-
const { ARTIFACTS_DIR } = await import('../shared/constants')
|
|
5220
|
+
const artifactListCmd: CommandHandler = async (ctx, _args) => {
|
|
5221
|
+
const { readManifest, getSessionArtifacts } = await import('../artifacts/manifest')
|
|
5222
|
+
const { artifactsRoot } = await import('../artifacts/paths')
|
|
5211
5223
|
|
|
5212
|
-
const dir =
|
|
5213
|
-
|
|
5224
|
+
const dir = artifactsRoot(process.cwd())
|
|
5225
|
+
// 会话 id 取自真实上下文,不是写死的 'session-1' —— 工具落盘时写的是真 id,
|
|
5226
|
+
// 写死的那一支必然过滤出空列表。
|
|
5227
|
+
const entries = getSessionArtifacts(dir, ctx.sessionId)
|
|
5214
5228
|
|
|
5215
5229
|
if (entries.length === 0) {
|
|
5216
5230
|
return {
|
|
@@ -5226,7 +5240,9 @@ const artifactListCmd: CommandHandler = async (_ctx, _args) => {
|
|
|
5226
5240
|
)
|
|
5227
5241
|
}
|
|
5228
5242
|
lines.push('', ` ${entries.length} artifact(s) — /artifact open <name> to view`)
|
|
5229
|
-
|
|
5243
|
+
// 画廊端口同样不写死:用最近一次落盘记下的那个(服务端会因端口占用自增)。
|
|
5244
|
+
const { port } = readManifest(dir)
|
|
5245
|
+
if (port) lines.push(` Gallery: http://localhost:${port}`)
|
|
5230
5246
|
|
|
5231
5247
|
return { content: lines.join('\n') }
|
|
5232
5248
|
}
|
|
@@ -1,127 +0,0 @@
|
|
|
1
|
-
import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, statSync } from 'node:fs'
|
|
2
|
-
import { join } from 'node:path'
|
|
3
|
-
import { homedir } from 'node:os'
|
|
4
|
-
|
|
5
|
-
const DEFAULT_VERSIONS_DIR = join(homedir(), '.mipham', 'artifacts')
|
|
6
|
-
|
|
7
|
-
export interface ArtifactVersion {
|
|
8
|
-
name: string
|
|
9
|
-
version: number
|
|
10
|
-
path: string
|
|
11
|
-
createdAt: string
|
|
12
|
-
size: number
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Manages artifact version snapshots on disk.
|
|
17
|
-
*
|
|
18
|
-
* Directory layout:
|
|
19
|
-
* <dir>/<name>/versions/v1.html
|
|
20
|
-
* <dir>/<name>/versions/v2.html
|
|
21
|
-
* <dir>/<name>/current.html (latest snapshot)
|
|
22
|
-
* <dir>/<name>/manifest.json ({ name, currentVersion, versionCount })
|
|
23
|
-
*/
|
|
24
|
-
export class ArtifactVersioning {
|
|
25
|
-
private dir: string
|
|
26
|
-
|
|
27
|
-
constructor(dir?: string) {
|
|
28
|
-
this.dir = dir ?? DEFAULT_VERSIONS_DIR
|
|
29
|
-
mkdirSync(this.dir, { recursive: true })
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** Save a new version of an artifact. Returns the assigned version number. */
|
|
33
|
-
saveVersion(name: string, content: string): number {
|
|
34
|
-
const artifactDir = join(this.dir, name)
|
|
35
|
-
const versionsDir = join(artifactDir, 'versions')
|
|
36
|
-
mkdirSync(versionsDir, { recursive: true })
|
|
37
|
-
|
|
38
|
-
// Determine next version number
|
|
39
|
-
const existing = this.listVersions(name)
|
|
40
|
-
const nextVersion = (existing.length > 0 ? Math.max(...existing.map((v) => v.version)) : 0) + 1
|
|
41
|
-
|
|
42
|
-
// Save versioned file
|
|
43
|
-
const versionPath = join(versionsDir, `v${nextVersion}.html`)
|
|
44
|
-
writeFileSync(versionPath, content, 'utf-8')
|
|
45
|
-
|
|
46
|
-
// Update current.html
|
|
47
|
-
writeFileSync(join(artifactDir, 'current.html'), content, 'utf-8')
|
|
48
|
-
|
|
49
|
-
// Update manifest
|
|
50
|
-
writeFileSync(
|
|
51
|
-
join(artifactDir, 'manifest.json'),
|
|
52
|
-
JSON.stringify(
|
|
53
|
-
{
|
|
54
|
-
name,
|
|
55
|
-
currentVersion: nextVersion,
|
|
56
|
-
versionCount: nextVersion,
|
|
57
|
-
},
|
|
58
|
-
null,
|
|
59
|
-
2,
|
|
60
|
-
),
|
|
61
|
-
)
|
|
62
|
-
|
|
63
|
-
return nextVersion
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/** List all saved versions for an artifact, newest first. */
|
|
67
|
-
listVersions(name: string): ArtifactVersion[] {
|
|
68
|
-
const versionsDir = join(this.dir, name, 'versions')
|
|
69
|
-
if (!existsSync(versionsDir)) return []
|
|
70
|
-
|
|
71
|
-
try {
|
|
72
|
-
return readdirSync(versionsDir)
|
|
73
|
-
.filter((f) => f.startsWith('v') && f.endsWith('.html'))
|
|
74
|
-
.map((f) => {
|
|
75
|
-
const vNum = parseInt(f.replace('v', '').replace('.html', ''), 10)
|
|
76
|
-
const path = join(versionsDir, f)
|
|
77
|
-
const stat = statSync(path)
|
|
78
|
-
return {
|
|
79
|
-
name,
|
|
80
|
-
version: vNum,
|
|
81
|
-
path,
|
|
82
|
-
createdAt: stat.birthtime.toISOString(),
|
|
83
|
-
size: stat.size,
|
|
84
|
-
}
|
|
85
|
-
})
|
|
86
|
-
.sort((a, b) => b.version - a.version)
|
|
87
|
-
} catch {
|
|
88
|
-
return []
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Get artifact content.
|
|
94
|
-
* - If a version number is given, returns that specific version.
|
|
95
|
-
* - Otherwise returns the latest (current.html).
|
|
96
|
-
* Returns null if the requested version does not exist.
|
|
97
|
-
*/
|
|
98
|
-
getVersion(name: string, version?: number): string | null {
|
|
99
|
-
const artifactDir = join(this.dir, name)
|
|
100
|
-
|
|
101
|
-
if (version !== undefined) {
|
|
102
|
-
const vPath = join(artifactDir, 'versions', `v${version}.html`)
|
|
103
|
-
return existsSync(vPath) ? readFileSync(vPath, 'utf-8') : null
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
const currentPath = join(artifactDir, 'current.html')
|
|
107
|
-
return existsSync(currentPath) ? readFileSync(currentPath, 'utf-8') : null
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/** Produce a simple line-by-line text diff between two versions. */
|
|
111
|
-
diff(name: string, v1: number, v2: number): string {
|
|
112
|
-
const content1 = this.getVersion(name, v1) || ''
|
|
113
|
-
const content2 = this.getVersion(name, v2) || ''
|
|
114
|
-
const lines1 = content1.split('\n')
|
|
115
|
-
const lines2 = content2.split('\n')
|
|
116
|
-
|
|
117
|
-
const diffLines: string[] = []
|
|
118
|
-
const maxLen = Math.max(lines1.length, lines2.length)
|
|
119
|
-
for (let i = 0; i < maxLen; i++) {
|
|
120
|
-
if (lines1[i] !== lines2[i]) {
|
|
121
|
-
if (lines1[i] !== undefined) diffLines.push(`- ${lines1[i]}`)
|
|
122
|
-
if (lines2[i] !== undefined) diffLines.push(`+ ${lines2[i]}`)
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
return diffLines.length > 0 ? diffLines.join('\n') : '(no changes)'
|
|
126
|
-
}
|
|
127
|
-
}
|