@yuchenrx/pmt-cli 0.1.2
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/AGENTS.md +128 -0
- package/README.md +134 -0
- package/README.zh.md +132 -0
- package/dist/pmt.exe +0 -0
- package/package.json +44 -0
- package/src/apply.ts +59 -0
- package/src/cli-name.ts +13 -0
- package/src/cli.ts +31 -0
- package/src/command-apply.ts +84 -0
- package/src/command-completion.ts +31 -0
- package/src/command-config.ts +244 -0
- package/src/command-link.ts +126 -0
- package/src/command-open.ts +47 -0
- package/src/command-search.ts +52 -0
- package/src/command-status.ts +27 -0
- package/src/commands.ts +165 -0
- package/src/completion.ts +96 -0
- package/src/config.ts +159 -0
- package/src/content-source.ts +14 -0
- package/src/helpers.ts +95 -0
- package/src/link-index.ts +101 -0
- package/src/link-manager.ts +180 -0
- package/src/link-repair.ts +30 -0
- package/src/link.ts +82 -0
- package/src/links-store.ts +47 -0
- package/src/lock.ts +59 -0
- package/src/prompt-root.ts +27 -0
- package/src/prompt.ts +20 -0
- package/src/rename-daemon.ts +98 -0
- package/src/rename-watcher.ts +110 -0
- package/src/scope.ts +13 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import omelette from 'omelette'
|
|
2
|
+
import { getCliName } from './cli-name'
|
|
3
|
+
import { loadMergedConfig } from './config'
|
|
4
|
+
import { listFiles } from './helpers'
|
|
5
|
+
import { loadPromptRoot } from './prompt-root'
|
|
6
|
+
|
|
7
|
+
const commands = [
|
|
8
|
+
'init',
|
|
9
|
+
'set',
|
|
10
|
+
'use',
|
|
11
|
+
'list',
|
|
12
|
+
'insert',
|
|
13
|
+
'apply',
|
|
14
|
+
'link',
|
|
15
|
+
'search',
|
|
16
|
+
'open',
|
|
17
|
+
'open-config',
|
|
18
|
+
'status',
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
const scopes = ['project', 'global']
|
|
22
|
+
|
|
23
|
+
const listPromptNames = async () => {
|
|
24
|
+
const info = await loadPromptRoot()
|
|
25
|
+
if (!info) return []
|
|
26
|
+
const files = await listFiles(info.root)
|
|
27
|
+
return files.sort()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const listSourceKeys = async () => {
|
|
31
|
+
const merged = await loadMergedConfig()
|
|
32
|
+
if (!merged.hasConfig) return []
|
|
33
|
+
return Object.keys(merged.resolvedSources).sort()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const hasCommand = (line: string, command: string) => line.trim().includes(` ${command}`)
|
|
37
|
+
|
|
38
|
+
const createCompletion = () => {
|
|
39
|
+
const cliName = getCliName()
|
|
40
|
+
const completion = omelette(`${cliName} <command> <arg1> <arg2> <arg3>`)
|
|
41
|
+
|
|
42
|
+
completion.on('command', ({ reply }) => {
|
|
43
|
+
reply(commands)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
completion.on('arg1', async ({ line, reply }) => {
|
|
47
|
+
if (hasCommand(line, 'link')) {
|
|
48
|
+
reply(await listPromptNames())
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (hasCommand(line, 'set') || hasCommand(line, 'use')) {
|
|
53
|
+
reply(await listSourceKeys())
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (hasCommand(line, 'open') || hasCommand(line, 'open-config')) {
|
|
58
|
+
reply(scopes)
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
completion.on('arg2', ({ line, reply }) => {
|
|
66
|
+
if (hasCommand(line, 'set') || hasCommand(line, 'use') || hasCommand(line, 'link')) {
|
|
67
|
+
reply(scopes)
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
completion.init()
|
|
72
|
+
return completion
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let completionInstance: ReturnType<typeof createCompletion> | null = null
|
|
76
|
+
|
|
77
|
+
const getCompletion = () => {
|
|
78
|
+
if (!completionInstance) {
|
|
79
|
+
completionInstance = createCompletion()
|
|
80
|
+
}
|
|
81
|
+
return completionInstance
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const initCompletion = () => {
|
|
85
|
+
getCompletion()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const installCompletion = () => {
|
|
89
|
+
const completion = getCompletion()
|
|
90
|
+
completion.setupShellInitFile()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const cleanupCompletion = () => {
|
|
94
|
+
const completion = getCompletion()
|
|
95
|
+
completion.cleanupShellInitFile()
|
|
96
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import { promises as fs } from 'node:fs'
|
|
4
|
+
import { cosmiconfig } from 'cosmiconfig'
|
|
5
|
+
import { isUrl } from './helpers'
|
|
6
|
+
|
|
7
|
+
export const CONFIG_NAME = '.promptslotrc.json'
|
|
8
|
+
export const DEFAULT_PLACEHOLDER = '{{promptslot}}'
|
|
9
|
+
export const DEFAULT_MARKERS = {
|
|
10
|
+
start: '<!-- promptslot:start -->',
|
|
11
|
+
end: '<!-- promptslot:end -->',
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type ConfigScope = 'project' | 'global'
|
|
15
|
+
|
|
16
|
+
export type PromptslotConfig = {
|
|
17
|
+
placeholder: string
|
|
18
|
+
markers: {
|
|
19
|
+
start: string
|
|
20
|
+
end: string
|
|
21
|
+
}
|
|
22
|
+
prompts_dir?: string | null
|
|
23
|
+
prompts_root?: string | null
|
|
24
|
+
link_default?: string | null
|
|
25
|
+
current: string | null
|
|
26
|
+
sources: Record<string, string>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type LoadedConfig = {
|
|
30
|
+
config: PromptslotConfig
|
|
31
|
+
configPath: string
|
|
32
|
+
baseDir: string
|
|
33
|
+
scope: ConfigScope
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type MergedConfig = {
|
|
37
|
+
config: PromptslotConfig
|
|
38
|
+
resolvedSources: Record<string, string>
|
|
39
|
+
hasConfig: boolean
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const explorer = cosmiconfig('promptslot', {
|
|
43
|
+
searchPlaces: [CONFIG_NAME],
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
export const createDefaultConfig = (): PromptslotConfig => ({
|
|
47
|
+
placeholder: DEFAULT_PLACEHOLDER,
|
|
48
|
+
markers: { ...DEFAULT_MARKERS },
|
|
49
|
+
prompts_dir: null,
|
|
50
|
+
prompts_root: null,
|
|
51
|
+
link_default: 'AGENTS.md',
|
|
52
|
+
current: null,
|
|
53
|
+
sources: {},
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
export const getProjectConfigPath = () => path.join(process.cwd(), CONFIG_NAME)
|
|
57
|
+
|
|
58
|
+
export const getGlobalConfigPath = () => path.join(os.homedir(), CONFIG_NAME)
|
|
59
|
+
|
|
60
|
+
const resolveConfigPath = (scope: ConfigScope) => {
|
|
61
|
+
if (scope === 'global') return getGlobalConfigPath()
|
|
62
|
+
return getProjectConfigPath()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const readConfigFile = async (scope: ConfigScope) => {
|
|
66
|
+
const configPath = resolveConfigPath(scope)
|
|
67
|
+
const result = await explorer.load(configPath).catch(() => null)
|
|
68
|
+
if (!result?.config) return null
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
config: result.config as PromptslotConfig,
|
|
72
|
+
configPath,
|
|
73
|
+
baseDir: path.dirname(configPath),
|
|
74
|
+
scope,
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const writeConfigFile = async (scope: ConfigScope, config: PromptslotConfig) => {
|
|
79
|
+
const configPath = resolveConfigPath(scope)
|
|
80
|
+
const json = JSON.stringify(config, null, 2)
|
|
81
|
+
await fs.writeFile(configPath, `${json}\n`)
|
|
82
|
+
return configPath
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const resolvePromptsRoot = (config: PromptslotConfig, baseDir: string) => {
|
|
86
|
+
if (config.prompts_root) {
|
|
87
|
+
return path.resolve(baseDir, config.prompts_root)
|
|
88
|
+
}
|
|
89
|
+
if (config.prompts_dir) {
|
|
90
|
+
return path.resolve(baseDir, config.prompts_dir)
|
|
91
|
+
}
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const resolveSourcePath = (
|
|
96
|
+
source: string,
|
|
97
|
+
config: PromptslotConfig,
|
|
98
|
+
baseDir: string,
|
|
99
|
+
) => {
|
|
100
|
+
if (isUrl(source)) return source
|
|
101
|
+
if (path.isAbsolute(source)) return source
|
|
102
|
+
const promptsRoot = resolvePromptsRoot(config, baseDir)
|
|
103
|
+
if (promptsRoot) return path.resolve(promptsRoot, source)
|
|
104
|
+
return path.resolve(baseDir, source)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export const ensurePromptsRoot = async (config: PromptslotConfig, baseDir: string) => {
|
|
108
|
+
const promptsRoot = resolvePromptsRoot(config, baseDir)
|
|
109
|
+
if (!promptsRoot) return null
|
|
110
|
+
await fs.mkdir(promptsRoot, { recursive: true })
|
|
111
|
+
return promptsRoot
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const mergeConfigs = (
|
|
115
|
+
globalConfig?: PromptslotConfig | null,
|
|
116
|
+
projectConfig?: PromptslotConfig | null,
|
|
117
|
+
) => {
|
|
118
|
+
const base = createDefaultConfig()
|
|
119
|
+
return {
|
|
120
|
+
...base,
|
|
121
|
+
...(globalConfig ?? {}),
|
|
122
|
+
...(projectConfig ?? {}),
|
|
123
|
+
markers: {
|
|
124
|
+
...base.markers,
|
|
125
|
+
...(globalConfig?.markers ?? {}),
|
|
126
|
+
...(projectConfig?.markers ?? {}),
|
|
127
|
+
},
|
|
128
|
+
sources: {
|
|
129
|
+
...(globalConfig?.sources ?? {}),
|
|
130
|
+
...(projectConfig?.sources ?? {}),
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const resolveSources = (loaded: LoadedConfig | null) => {
|
|
136
|
+
if (!loaded) return {}
|
|
137
|
+
return Object.fromEntries(
|
|
138
|
+
Object.entries(loaded.config.sources).map(([name, source]) => [
|
|
139
|
+
name,
|
|
140
|
+
resolveSourcePath(source, loaded.config, loaded.baseDir),
|
|
141
|
+
]),
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export const loadMergedConfig = async (): Promise<MergedConfig> => {
|
|
146
|
+
const globalConfig = await readConfigFile('global')
|
|
147
|
+
const projectConfig = await readConfigFile('project')
|
|
148
|
+
const config = mergeConfigs(globalConfig?.config, projectConfig?.config)
|
|
149
|
+
const resolvedSources = {
|
|
150
|
+
...resolveSources(globalConfig),
|
|
151
|
+
...resolveSources(projectConfig),
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
config,
|
|
156
|
+
resolvedSources,
|
|
157
|
+
hasConfig: Boolean(globalConfig || projectConfig),
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs'
|
|
2
|
+
import { isUrl } from './helpers'
|
|
3
|
+
|
|
4
|
+
export const readSourceText = async (sourcePath: string) => {
|
|
5
|
+
if (isUrl(sourcePath)) {
|
|
6
|
+
const response = await fetch(sourcePath)
|
|
7
|
+
if (!response.ok) {
|
|
8
|
+
throw new Error(`无法读取远程文件: ${response.status}`)
|
|
9
|
+
}
|
|
10
|
+
return response.text()
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return fs.readFile(sourcePath, 'utf8')
|
|
14
|
+
}
|
package/src/helpers.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/// <reference types="bun" />
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { promises as fs } from 'node:fs'
|
|
4
|
+
|
|
5
|
+
export const resolvePath = (input: string, baseDir = process.cwd()) =>
|
|
6
|
+
path.resolve(baseDir, input)
|
|
7
|
+
|
|
8
|
+
export const fileExists = async (filePath: string) =>
|
|
9
|
+
fs.access(filePath).then(() => true).catch(() => false)
|
|
10
|
+
|
|
11
|
+
export const isSubpath = (parent: string, child: string) => {
|
|
12
|
+
const relative = path.relative(parent, child)
|
|
13
|
+
return Boolean(relative) && !relative.startsWith('..') && !path.isAbsolute(relative)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const listFiles = async (dirPath: string) => {
|
|
17
|
+
const entries = await fs.readdir(dirPath, { withFileTypes: true }).catch(() => [])
|
|
18
|
+
return entries.filter((entry) => entry.isFile()).map((entry) => entry.name)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const isFile = async (filePath: string) => {
|
|
22
|
+
const stats = await fs.stat(filePath).catch(() => null)
|
|
23
|
+
if (!stats) return false
|
|
24
|
+
return stats.isFile()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const isUrl = (value: string) => /^https?:\/\//i.test(value)
|
|
28
|
+
|
|
29
|
+
export const getSourceExtension = (sourcePath: string) => {
|
|
30
|
+
if (isUrl(sourcePath)) {
|
|
31
|
+
const url = new URL(sourcePath)
|
|
32
|
+
return path.extname(url.pathname)
|
|
33
|
+
}
|
|
34
|
+
return path.extname(sourcePath)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const ensureTargetExtension = (
|
|
38
|
+
targetPath: string,
|
|
39
|
+
sourcePath: string,
|
|
40
|
+
fallbackExtension = '.md',
|
|
41
|
+
) => {
|
|
42
|
+
const sourceExtension = getSourceExtension(sourcePath) || fallbackExtension
|
|
43
|
+
const targetExtension = path.extname(targetPath)
|
|
44
|
+
|
|
45
|
+
if (!sourceExtension) return targetPath
|
|
46
|
+
if (!targetExtension) return `${targetPath}${sourceExtension}`
|
|
47
|
+
if (targetExtension === sourceExtension) return targetPath
|
|
48
|
+
|
|
49
|
+
return `${targetPath.slice(0, -targetExtension.length)}${sourceExtension}`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const parseContentDisposition = (value: string | null) => {
|
|
53
|
+
if (!value) return null
|
|
54
|
+
|
|
55
|
+
const filenameMatch = /filename\*?=(?:UTF-8''|"?)([^";]+)/i.exec(value)
|
|
56
|
+
if (!filenameMatch?.[1]) return null
|
|
57
|
+
|
|
58
|
+
const filename = filenameMatch[1].trim()
|
|
59
|
+
if (!filename) return null
|
|
60
|
+
|
|
61
|
+
return decodeURIComponent(filename)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const getUrlBasename = (value: string) => {
|
|
65
|
+
try {
|
|
66
|
+
const url = new URL(value)
|
|
67
|
+
if (url.pathname.endsWith('/')) return null
|
|
68
|
+
|
|
69
|
+
const base = path.basename(url.pathname)
|
|
70
|
+
if (!base || base === '/') return null
|
|
71
|
+
return base
|
|
72
|
+
} catch {
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const resolveTargets = async (
|
|
78
|
+
inputs: string[],
|
|
79
|
+
excludedFiles: string[],
|
|
80
|
+
) => {
|
|
81
|
+
if (inputs.length) return inputs
|
|
82
|
+
|
|
83
|
+
const glob = new Bun.Glob('**/*.md')
|
|
84
|
+
const targets: string[] = []
|
|
85
|
+
const excluded = new Set(excludedFiles.map((file) => path.resolve(file)))
|
|
86
|
+
|
|
87
|
+
for await (const file of glob.scan({ cwd: process.cwd(), onlyFiles: true })) {
|
|
88
|
+
if (file.includes('node_modules')) continue
|
|
89
|
+
const resolved = path.resolve(file)
|
|
90
|
+
if (excluded.has(resolved)) continue
|
|
91
|
+
targets.push(file)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return targets
|
|
95
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { promises as fs } from 'node:fs'
|
|
3
|
+
|
|
4
|
+
export type LinkIndex = {
|
|
5
|
+
sources: Record<string, string[]>
|
|
6
|
+
roots: string[]
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const INDEX_FILE = '.promptslot-index.json'
|
|
10
|
+
|
|
11
|
+
const resolveIndexPath = () => path.join(process.cwd(), INDEX_FILE)
|
|
12
|
+
|
|
13
|
+
const normalizeTarget = (targetPath: string) =>
|
|
14
|
+
path.relative(process.cwd(), path.resolve(targetPath))
|
|
15
|
+
|
|
16
|
+
const normalizeRoot = (rootPath: string) => path.resolve(process.cwd(), rootPath)
|
|
17
|
+
|
|
18
|
+
export const readIndex = async (): Promise<LinkIndex> => {
|
|
19
|
+
const indexPath = resolveIndexPath()
|
|
20
|
+
const content = await fs.readFile(indexPath, 'utf8').catch(() => null)
|
|
21
|
+
if (!content) return { sources: {}, roots: [] }
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const parsed = JSON.parse(content) as LinkIndex
|
|
25
|
+
return {
|
|
26
|
+
sources: parsed.sources ?? {},
|
|
27
|
+
roots: parsed.roots ?? [],
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
return { sources: {}, roots: [] }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const writeIndex = async (index: LinkIndex) => {
|
|
35
|
+
const indexPath = resolveIndexPath()
|
|
36
|
+
const json = JSON.stringify(index, null, 2)
|
|
37
|
+
await fs.writeFile(indexPath, `${json}\n`)
|
|
38
|
+
return indexPath
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const addRoot = async (rootPath: string) => {
|
|
42
|
+
const index = await readIndex()
|
|
43
|
+
const normalized = normalizeRoot(rootPath)
|
|
44
|
+
if (!index.roots.includes(normalized)) {
|
|
45
|
+
index.roots.push(normalized)
|
|
46
|
+
await writeIndex(index)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const recordLink = async (source: string, targetPath: string, rootPath: string) => {
|
|
51
|
+
const index = await readIndex()
|
|
52
|
+
const normalizedTarget = normalizeTarget(targetPath)
|
|
53
|
+
const entries = index.sources[source] ?? []
|
|
54
|
+
|
|
55
|
+
if (!entries.includes(normalizedTarget)) {
|
|
56
|
+
entries.push(normalizedTarget)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
index.sources[source] = entries
|
|
60
|
+
|
|
61
|
+
const normalizedRoot = normalizeRoot(rootPath)
|
|
62
|
+
if (!index.roots.includes(normalizedRoot)) {
|
|
63
|
+
index.roots.push(normalizedRoot)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
await writeIndex(index)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const replaceLinkTarget = async (
|
|
70
|
+
source: string,
|
|
71
|
+
oldTargetPath: string,
|
|
72
|
+
newTargetPath: string,
|
|
73
|
+
) => {
|
|
74
|
+
const index = await readIndex()
|
|
75
|
+
const normalizedOld = normalizeTarget(oldTargetPath)
|
|
76
|
+
const normalizedNew = normalizeTarget(newTargetPath)
|
|
77
|
+
const entries = index.sources[source] ?? []
|
|
78
|
+
|
|
79
|
+
const next = entries.filter((item) => item !== normalizedOld)
|
|
80
|
+
if (!next.includes(normalizedNew)) {
|
|
81
|
+
next.push(normalizedNew)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
index.sources[source] = next
|
|
85
|
+
await writeIndex(index)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const removeLinkTarget = async (source: string, targetPath: string) => {
|
|
89
|
+
const index = await readIndex()
|
|
90
|
+
const normalized = normalizeTarget(targetPath)
|
|
91
|
+
const entries = index.sources[source] ?? []
|
|
92
|
+
const next = entries.filter((item) => item !== normalized)
|
|
93
|
+
|
|
94
|
+
if (!next.length) {
|
|
95
|
+
delete index.sources[source]
|
|
96
|
+
} else {
|
|
97
|
+
index.sources[source] = next
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
await writeIndex(index)
|
|
101
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import crypto from 'node:crypto'
|
|
4
|
+
import { readLinkMeta, stripLinkHeader, writeLinkFile } from './link'
|
|
5
|
+
import { waitForUnlock, withLock } from './lock'
|
|
6
|
+
import type { LinkMode } from './links-store'
|
|
7
|
+
import { isFile, isUrl } from './helpers'
|
|
8
|
+
|
|
9
|
+
const getLockPath = (sourcePath: string) => `${sourcePath}.lock`
|
|
10
|
+
|
|
11
|
+
const isRemote = (sourcePath: string) => isUrl(sourcePath)
|
|
12
|
+
|
|
13
|
+
const hashContent = (content: string) =>
|
|
14
|
+
crypto.createHash('sha256').update(content).digest('hex')
|
|
15
|
+
|
|
16
|
+
const readSource = async (sourcePath: string) => {
|
|
17
|
+
const content = await fs.readFile(sourcePath, 'utf8')
|
|
18
|
+
return { content, hash: hashContent(content) }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const readRemote = async (sourcePath: string) => {
|
|
22
|
+
const response = await fetch(sourcePath)
|
|
23
|
+
if (!response.ok) {
|
|
24
|
+
throw new Error(`无法读取远程文件: ${response.status}`)
|
|
25
|
+
}
|
|
26
|
+
const content = await response.text()
|
|
27
|
+
return { content, hash: hashContent(content) }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const readSourceContent = async (sourcePath: string) => {
|
|
31
|
+
if (isRemote(sourcePath)) {
|
|
32
|
+
return readRemote(sourcePath)
|
|
33
|
+
}
|
|
34
|
+
return readSource(sourcePath)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const createLink = async (sourcePath: string, targetPath: string) => {
|
|
38
|
+
const source = await readSourceContent(sourcePath)
|
|
39
|
+
await writeLinkFile(targetPath, sourcePath, source.content, source.hash)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const createAutoLink = async (sourcePath: string, targetPath: string) => {
|
|
43
|
+
if (!isRemote(sourcePath)) {
|
|
44
|
+
try {
|
|
45
|
+
await fs.link(sourcePath, targetPath)
|
|
46
|
+
return { mode: 'hardlink' as LinkMode }
|
|
47
|
+
} catch (error) {
|
|
48
|
+
const err = error as NodeJS.ErrnoException
|
|
49
|
+
const fallback = err.code === 'EXDEV' || err.code === 'EPERM' || err.code === 'EACCES'
|
|
50
|
+
if (!fallback) throw error
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
await fs.symlink(sourcePath, targetPath, 'file')
|
|
55
|
+
return { mode: 'symlink' as LinkMode }
|
|
56
|
+
} catch (error) {
|
|
57
|
+
const err = error as NodeJS.ErrnoException
|
|
58
|
+
const fallback = err.code === 'EPERM' || err.code === 'EACCES'
|
|
59
|
+
if (!fallback) throw error
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
await createLink(sourcePath, targetPath)
|
|
64
|
+
return { mode: 'meta' as LinkMode }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const pullLink = async (sourcePath: string, targetPath: string) => {
|
|
68
|
+
if (!isRemote(sourcePath)) {
|
|
69
|
+
const lockPath = getLockPath(sourcePath)
|
|
70
|
+
await waitForUnlock(lockPath)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const source = await readSourceContent(sourcePath)
|
|
74
|
+
await writeLinkFile(targetPath, sourcePath, source.content, source.hash)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const resolveConflict = (sourceHash: string, linkHash: string | null, payloadHash: string) => {
|
|
78
|
+
if (!linkHash) return null
|
|
79
|
+
if (sourceHash !== linkHash && payloadHash !== linkHash) {
|
|
80
|
+
return '检测到冲突,请先 pull 更新后再写入'
|
|
81
|
+
}
|
|
82
|
+
return null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const resolveScopeChange = (sourceHash: string, linkHash: string | null, payloadHash: string) => {
|
|
86
|
+
if (!linkHash) return null
|
|
87
|
+
if (sourceHash !== linkHash && payloadHash === linkHash) {
|
|
88
|
+
return '源文件已更新,请先 pull 同步'
|
|
89
|
+
}
|
|
90
|
+
return null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const pushLink = async (sourcePath: string, targetPath: string) => {
|
|
94
|
+
if (isRemote(sourcePath)) {
|
|
95
|
+
throw new Error('远程文件不支持写入')
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const lockPath = getLockPath(sourcePath)
|
|
99
|
+
const link = await readLinkMeta(targetPath)
|
|
100
|
+
if (!link) {
|
|
101
|
+
throw new Error('文件不是 link 文件')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const payload = stripLinkHeader(link.content)
|
|
105
|
+
const payloadHash = hashContent(payload)
|
|
106
|
+
|
|
107
|
+
await withLock(lockPath, async () => {
|
|
108
|
+
const source = await readSourceContent(sourcePath)
|
|
109
|
+
const conflict = resolveConflict(source.hash, link.meta.hash, payloadHash)
|
|
110
|
+
if (conflict) {
|
|
111
|
+
throw new Error(conflict)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const sourceChanged = resolveScopeChange(source.hash, link.meta.hash, payloadHash)
|
|
115
|
+
if (sourceChanged) {
|
|
116
|
+
throw new Error(sourceChanged)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (payloadHash !== source.hash) {
|
|
120
|
+
await fs.writeFile(sourcePath, payload)
|
|
121
|
+
}
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
const finalContent = await readSourceContent(sourcePath)
|
|
125
|
+
await writeLinkFile(targetPath, sourcePath, finalContent.content, finalContent.hash)
|
|
126
|
+
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export const syncLink = async (sourcePath: string, targetPath: string) => {
|
|
130
|
+
if (isRemote(sourcePath)) {
|
|
131
|
+
throw new Error('远程文件不支持写入')
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const lockPath = getLockPath(sourcePath)
|
|
135
|
+
const link = await readLinkMeta(targetPath)
|
|
136
|
+
if (!link) {
|
|
137
|
+
throw new Error('文件不是 link 文件')
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const payload = stripLinkHeader(link.content)
|
|
141
|
+
const payloadHash = hashContent(payload)
|
|
142
|
+
|
|
143
|
+
await withLock(lockPath, async () => {
|
|
144
|
+
const source = await readSourceContent(sourcePath)
|
|
145
|
+
|
|
146
|
+
const conflict = resolveConflict(source.hash, link.meta.hash, payloadHash)
|
|
147
|
+
if (conflict) {
|
|
148
|
+
throw new Error(conflict)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const sourceChanged = resolveScopeChange(source.hash, link.meta.hash, payloadHash)
|
|
152
|
+
if (sourceChanged) {
|
|
153
|
+
throw new Error(sourceChanged)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (payloadHash !== link.meta.hash && payloadHash !== source.hash) {
|
|
157
|
+
await fs.writeFile(sourcePath, payload)
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
const finalContent = await readSourceContent(sourcePath)
|
|
162
|
+
await writeLinkFile(targetPath, sourcePath, finalContent.content, finalContent.hash)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export const resolvePromptPath = (root: string, name: string) => {
|
|
166
|
+
const direct = path.resolve(root, name)
|
|
167
|
+
return direct
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export const resolvePromptFile = async (root: string, name: string) => {
|
|
171
|
+
const direct = resolvePromptPath(root, name)
|
|
172
|
+
const directExists = await isFile(direct)
|
|
173
|
+
if (directExists) return direct
|
|
174
|
+
|
|
175
|
+
const withMd = resolvePromptPath(root, `${name}.md`)
|
|
176
|
+
const withMdExists = await isFile(withMd)
|
|
177
|
+
if (withMdExists) return withMd
|
|
178
|
+
|
|
179
|
+
return null
|
|
180
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/// <reference types="bun" />
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { readLinks, writeLinks } from './links-store'
|
|
4
|
+
import { removeLinkTarget } from './link-index'
|
|
5
|
+
import { fileExists } from './helpers'
|
|
6
|
+
|
|
7
|
+
export const autoRepairLinks = async () => {
|
|
8
|
+
const entries = await readLinks()
|
|
9
|
+
const keys = Object.keys(entries)
|
|
10
|
+
if (!keys.length) return
|
|
11
|
+
|
|
12
|
+
let changed = false
|
|
13
|
+
|
|
14
|
+
for (const key of keys) {
|
|
15
|
+
const entry = entries[key]
|
|
16
|
+
if (entry.mode !== 'meta') continue
|
|
17
|
+
|
|
18
|
+
const targetPath = path.resolve(process.cwd(), key)
|
|
19
|
+
const exists = await fileExists(targetPath)
|
|
20
|
+
if (exists) continue
|
|
21
|
+
|
|
22
|
+
delete entries[key]
|
|
23
|
+
await removeLinkTarget(entry.source, targetPath)
|
|
24
|
+
changed = true
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (changed) {
|
|
28
|
+
await writeLinks(entries)
|
|
29
|
+
}
|
|
30
|
+
}
|