@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
package/src/link.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
const LINK_PREFIX = '<!-- promptslot-link:'
|
|
5
|
+
const LINK_SUFFIX = '-->'
|
|
6
|
+
|
|
7
|
+
type LinkMeta = {
|
|
8
|
+
source: string
|
|
9
|
+
hash: string | null
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const buildHeader = (meta: LinkMeta) => {
|
|
13
|
+
const payload = JSON.stringify(meta)
|
|
14
|
+
return `${LINK_PREFIX} ${payload} ${LINK_SUFFIX}`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const splitLines = (content: string) => content.split(/\r?\n/)
|
|
18
|
+
|
|
19
|
+
const parseHeaderValue = (value: string): LinkMeta | null => {
|
|
20
|
+
const trimmed = value.trim()
|
|
21
|
+
if (!trimmed) return null
|
|
22
|
+
|
|
23
|
+
if (trimmed.startsWith('{')) {
|
|
24
|
+
const parsed = JSON.parse(trimmed) as LinkMeta
|
|
25
|
+
if (!parsed?.source) return null
|
|
26
|
+
return { source: parsed.source, hash: parsed.hash ?? null }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return { source: trimmed, hash: null }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const parseLinkHeader = (content: string) => {
|
|
33
|
+
const [firstLine] = splitLines(content)
|
|
34
|
+
if (!firstLine) return null
|
|
35
|
+
const trimmed = firstLine.trim()
|
|
36
|
+
if (!trimmed.startsWith(LINK_PREFIX) || !trimmed.endsWith(LINK_SUFFIX)) return null
|
|
37
|
+
|
|
38
|
+
const inner = trimmed.slice(LINK_PREFIX.length, trimmed.length - LINK_SUFFIX.length).trim()
|
|
39
|
+
return parseHeaderValue(inner)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const stripLinkHeader = (content: string) => {
|
|
43
|
+
const lines = splitLines(content)
|
|
44
|
+
if (!lines.length) return content
|
|
45
|
+
|
|
46
|
+
const header = parseLinkHeader(lines[0])
|
|
47
|
+
if (!header) return content
|
|
48
|
+
|
|
49
|
+
return lines.slice(1).join('\n').replace(/^\n+/, '')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const buildLinkContent = (meta: LinkMeta, payload: string) => {
|
|
53
|
+
const header = buildHeader(meta)
|
|
54
|
+
const trimmedPayload = payload.replace(/\s+$/, '')
|
|
55
|
+
return `${header}\n${trimmedPayload}\n`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const readLinkMeta = async (targetPath: string) => {
|
|
59
|
+
const content = await fs.readFile(targetPath, 'utf8')
|
|
60
|
+
const meta = parseLinkHeader(content)
|
|
61
|
+
if (!meta) return null
|
|
62
|
+
return { meta, content }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const readLinkTarget = async (targetPath: string) => {
|
|
66
|
+
const link = await readLinkMeta(targetPath)
|
|
67
|
+
if (!link) return null
|
|
68
|
+
return link.meta.source
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const writeLinkFile = async (
|
|
72
|
+
targetPath: string,
|
|
73
|
+
sourcePath: string,
|
|
74
|
+
payload: string,
|
|
75
|
+
hash: string | null,
|
|
76
|
+
) => {
|
|
77
|
+
const absoluteSource = path.resolve(sourcePath)
|
|
78
|
+
const content = buildLinkContent({ source: absoluteSource, hash }, payload)
|
|
79
|
+
await fs.writeFile(targetPath, content)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type { LinkMeta }
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { promises as fs } from 'node:fs'
|
|
3
|
+
|
|
4
|
+
export type LinkMode = 'hardlink' | 'symlink' | 'meta'
|
|
5
|
+
|
|
6
|
+
export type LinkEntry = {
|
|
7
|
+
source: string
|
|
8
|
+
mode: LinkMode
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const LINKS_FILE = '.promptslot-links.json'
|
|
12
|
+
|
|
13
|
+
const resolveStorePath = () => path.join(process.cwd(), LINKS_FILE)
|
|
14
|
+
|
|
15
|
+
const normalizeTarget = (targetPath: string) =>
|
|
16
|
+
path.relative(process.cwd(), path.resolve(targetPath))
|
|
17
|
+
|
|
18
|
+
export const readLinks = async (): Promise<Record<string, LinkEntry>> => {
|
|
19
|
+
const storePath = resolveStorePath()
|
|
20
|
+
const content = await fs.readFile(storePath, 'utf8').catch(() => null)
|
|
21
|
+
if (!content) return {}
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const parsed = JSON.parse(content) as Record<string, LinkEntry>
|
|
25
|
+
return parsed ?? {}
|
|
26
|
+
} catch {
|
|
27
|
+
return {}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const writeLinks = async (entries: Record<string, LinkEntry>) => {
|
|
32
|
+
const storePath = resolveStorePath()
|
|
33
|
+
const json = JSON.stringify(entries, null, 2)
|
|
34
|
+
await fs.writeFile(storePath, `${json}\n`)
|
|
35
|
+
return storePath
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const upsertLink = async (targetPath: string, entry: LinkEntry) => {
|
|
39
|
+
const entries = await readLinks()
|
|
40
|
+
entries[normalizeTarget(targetPath)] = entry
|
|
41
|
+
await writeLinks(entries)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const getLinkEntry = async (targetPath: string) => {
|
|
45
|
+
const entries = await readLinks()
|
|
46
|
+
return entries[normalizeTarget(targetPath)] ?? null
|
|
47
|
+
}
|
package/src/lock.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs'
|
|
2
|
+
|
|
3
|
+
const LOCK_TIMEOUT_MS = 10000
|
|
4
|
+
const LOCK_INTERVAL_MS = 200
|
|
5
|
+
|
|
6
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
7
|
+
|
|
8
|
+
const tryCreateLock = async (lockPath: string) => {
|
|
9
|
+
try {
|
|
10
|
+
const handle = await fs.open(lockPath, 'wx')
|
|
11
|
+
await handle.close()
|
|
12
|
+
return true
|
|
13
|
+
} catch (error) {
|
|
14
|
+
const err = error as NodeJS.ErrnoException
|
|
15
|
+
if (err.code === 'EEXIST') return false
|
|
16
|
+
throw error
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const waitForUnlock = async (lockPath: string) => {
|
|
21
|
+
const start = Date.now()
|
|
22
|
+
while (true) {
|
|
23
|
+
const exists = await fs
|
|
24
|
+
.access(lockPath)
|
|
25
|
+
.then(() => true)
|
|
26
|
+
.catch(() => false)
|
|
27
|
+
|
|
28
|
+
if (!exists) return
|
|
29
|
+
|
|
30
|
+
const elapsed = Date.now() - start
|
|
31
|
+
if (elapsed >= LOCK_TIMEOUT_MS) {
|
|
32
|
+
throw new Error('写入锁等待超时,请稍后重试')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
await sleep(LOCK_INTERVAL_MS)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const withLock = async <T>(lockPath: string, task: () => Promise<T>) => {
|
|
40
|
+
const start = Date.now()
|
|
41
|
+
|
|
42
|
+
while (true) {
|
|
43
|
+
const created = await tryCreateLock(lockPath)
|
|
44
|
+
if (created) break
|
|
45
|
+
|
|
46
|
+
const elapsed = Date.now() - start
|
|
47
|
+
if (elapsed >= LOCK_TIMEOUT_MS) {
|
|
48
|
+
throw new Error('写入锁等待超时,请稍后重试')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
await sleep(LOCK_INTERVAL_MS)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
return await task()
|
|
56
|
+
} finally {
|
|
57
|
+
await fs.unlink(lockPath).catch(() => null)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { readConfigFile, resolvePromptsRoot, type ConfigScope } from './config'
|
|
2
|
+
|
|
3
|
+
export type PromptRootInfo = {
|
|
4
|
+
root: string
|
|
5
|
+
scope: ConfigScope
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const resolveRoot = async (scope: ConfigScope) => {
|
|
9
|
+
const config = await readConfigFile(scope)
|
|
10
|
+
if (!config) return null
|
|
11
|
+
const root = resolvePromptsRoot(config.config, config.baseDir)
|
|
12
|
+
if (!root) return null
|
|
13
|
+
return { root, scope }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const loadPromptRoot = async (scope?: ConfigScope): Promise<PromptRootInfo | null> => {
|
|
17
|
+
if (scope === 'project') return resolveRoot('project')
|
|
18
|
+
if (scope === 'global') return resolveRoot('global')
|
|
19
|
+
|
|
20
|
+
const project = await resolveRoot('project')
|
|
21
|
+
if (project) return project
|
|
22
|
+
|
|
23
|
+
const globalConfig = await resolveRoot('global')
|
|
24
|
+
if (globalConfig) return globalConfig
|
|
25
|
+
|
|
26
|
+
return null
|
|
27
|
+
}
|
package/src/prompt.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline/promises'
|
|
2
|
+
|
|
3
|
+
export const ask = async (question: string, fallback: string) => {
|
|
4
|
+
if (!process.stdin.isTTY) return fallback
|
|
5
|
+
|
|
6
|
+
const prompt = `${question} (${fallback}): `
|
|
7
|
+
const readline = createInterface({
|
|
8
|
+
input: process.stdin,
|
|
9
|
+
output: process.stdout,
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
const answer = await readline.question(prompt)
|
|
14
|
+
const trimmed = answer.trim()
|
|
15
|
+
if (!trimmed) return fallback
|
|
16
|
+
return trimmed
|
|
17
|
+
} finally {
|
|
18
|
+
readline.close()
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { promises as fs, watch } from 'node:fs'
|
|
3
|
+
import { startRenameWatcher } from './rename-watcher'
|
|
4
|
+
import { readLinks } from './links-store'
|
|
5
|
+
|
|
6
|
+
const PID_FILE = '.promptslot-rename.pid'
|
|
7
|
+
const STORE_FILE = '.promptslot-links.json'
|
|
8
|
+
|
|
9
|
+
const getPidPath = () => path.join(process.cwd(), PID_FILE)
|
|
10
|
+
const getStorePath = () => path.join(process.cwd(), STORE_FILE)
|
|
11
|
+
|
|
12
|
+
const isProcessAlive = (pid: number) => {
|
|
13
|
+
try {
|
|
14
|
+
process.kill(pid, 0)
|
|
15
|
+
return true
|
|
16
|
+
} catch {
|
|
17
|
+
return false
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const readPid = async () => {
|
|
22
|
+
const content = await fs.readFile(getPidPath(), 'utf8').catch(() => null)
|
|
23
|
+
if (!content) return null
|
|
24
|
+
const pid = Number.parseInt(content, 10)
|
|
25
|
+
if (!Number.isFinite(pid)) return null
|
|
26
|
+
return pid
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const writePid = async () => {
|
|
30
|
+
await fs.writeFile(getPidPath(), `${process.pid}\n`)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const deletePid = async () => {
|
|
34
|
+
await fs.unlink(getPidPath()).catch(() => null)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const watchStore = (onChange: () => void) => {
|
|
38
|
+
const storePath = getStorePath()
|
|
39
|
+
const watcher = watch(storePath, { persistent: true }, () => {
|
|
40
|
+
onChange()
|
|
41
|
+
})
|
|
42
|
+
return watcher
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const ensureRenameDaemon = async () => {
|
|
46
|
+
const pid = await readPid()
|
|
47
|
+
if (pid && isProcessAlive(pid)) return false
|
|
48
|
+
|
|
49
|
+
await deletePid()
|
|
50
|
+
|
|
51
|
+
const { spawn } = await import('node:child_process')
|
|
52
|
+
const execPath = process.argv[0]
|
|
53
|
+
const scriptPath = process.argv[1]
|
|
54
|
+
const args: string[] = []
|
|
55
|
+
|
|
56
|
+
if (scriptPath && path.extname(scriptPath) === '.ts') {
|
|
57
|
+
args.push(scriptPath)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
args.push('rename-daemon')
|
|
61
|
+
|
|
62
|
+
const proc = spawn(execPath, args, {
|
|
63
|
+
detached: true,
|
|
64
|
+
stdio: 'ignore',
|
|
65
|
+
windowsHide: true,
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
proc.unref()
|
|
69
|
+
return true
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const runRenameDaemon = async () => {
|
|
73
|
+
const entries = await readLinks()
|
|
74
|
+
if (!Object.keys(entries).length) return
|
|
75
|
+
|
|
76
|
+
await writePid()
|
|
77
|
+
|
|
78
|
+
let watcher = await startRenameWatcher()
|
|
79
|
+
|
|
80
|
+
const storeWatcher = await watchStore(() => {
|
|
81
|
+
watcher?.close()
|
|
82
|
+
void startRenameWatcher().then((next) => {
|
|
83
|
+
watcher = next
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const shutdown = async () => {
|
|
88
|
+
storeWatcher.close()
|
|
89
|
+
watcher?.close()
|
|
90
|
+
await deletePid()
|
|
91
|
+
process.exit(0)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
process.on('SIGINT', shutdown)
|
|
95
|
+
process.on('SIGTERM', shutdown)
|
|
96
|
+
|
|
97
|
+
await new Promise(() => {})
|
|
98
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import Watcher from 'watcher'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { readLinkMeta } from './link'
|
|
4
|
+
import { replaceLinkTarget } from './link-index'
|
|
5
|
+
import { fileExists } from './helpers'
|
|
6
|
+
import { readLinks, writeLinks } from './links-store'
|
|
7
|
+
|
|
8
|
+
const normalizeTarget = (targetPath: string) =>
|
|
9
|
+
path.relative(process.cwd(), path.resolve(targetPath))
|
|
10
|
+
|
|
11
|
+
const loadDirectories = async () => {
|
|
12
|
+
const entries = await readLinks()
|
|
13
|
+
const dirs = new Set<string>()
|
|
14
|
+
|
|
15
|
+
for (const target of Object.keys(entries)) {
|
|
16
|
+
const dir = path.dirname(path.resolve(process.cwd(), target))
|
|
17
|
+
dirs.add(dir)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return Array.from(dirs)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const updateLinkRecord = async (oldPath: string, newPath: string, source: string) => {
|
|
24
|
+
const entries = await readLinks()
|
|
25
|
+
const oldKey = normalizeTarget(oldPath)
|
|
26
|
+
const newKey = normalizeTarget(newPath)
|
|
27
|
+
|
|
28
|
+
const existing = entries[oldKey]
|
|
29
|
+
|
|
30
|
+
if (existing) {
|
|
31
|
+
entries[newKey] = existing
|
|
32
|
+
delete entries[oldKey]
|
|
33
|
+
} else {
|
|
34
|
+
entries[newKey] = { source, mode: 'meta' }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
await writeLinks(entries)
|
|
38
|
+
await replaceLinkTarget(source, oldPath, newPath)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const resolveSource = async (filePath: string) => {
|
|
42
|
+
const meta = await readLinkMeta(filePath).catch(() => null)
|
|
43
|
+
return meta?.meta.source ?? null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const startRenameWatcher = async () => {
|
|
47
|
+
const roots = await loadDirectories()
|
|
48
|
+
if (!roots.length) return null
|
|
49
|
+
|
|
50
|
+
const watcher = new Watcher(roots, {
|
|
51
|
+
renameDetection: true,
|
|
52
|
+
renameTimeout: 500,
|
|
53
|
+
ignore: /node_modules|\.git/,
|
|
54
|
+
recursive: true,
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
watcher.on('rename', async (oldPath: string, newPath: string) => {
|
|
58
|
+
const oldExists = await fileExists(oldPath)
|
|
59
|
+
const newExists = await fileExists(newPath)
|
|
60
|
+
|
|
61
|
+
if (oldExists || !newExists) return
|
|
62
|
+
|
|
63
|
+
const source = await resolveSource(newPath)
|
|
64
|
+
if (!source) return
|
|
65
|
+
|
|
66
|
+
await updateLinkRecord(oldPath, newPath, source)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
watcher.on('delete', async (filePath: string) => {
|
|
70
|
+
const entries = await readLinks()
|
|
71
|
+
const key = normalizeTarget(filePath)
|
|
72
|
+
if (!entries[key]) return
|
|
73
|
+
|
|
74
|
+
delete entries[key]
|
|
75
|
+
await writeLinks(entries)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
watcher.on('add', async (filePath: string) => {
|
|
79
|
+
const source = await resolveSource(filePath)
|
|
80
|
+
if (!source) return
|
|
81
|
+
|
|
82
|
+
const entries = await readLinks()
|
|
83
|
+
const key = normalizeTarget(filePath)
|
|
84
|
+
if (entries[key]) return
|
|
85
|
+
|
|
86
|
+
entries[key] = { source, mode: 'meta' }
|
|
87
|
+
await writeLinks(entries)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
watcher.on('renameDir', async (oldPath: string, newPath: string) => {
|
|
91
|
+
const entries = await readLinks()
|
|
92
|
+
const pairs = Object.entries(entries)
|
|
93
|
+
|
|
94
|
+
for (const [target] of pairs) {
|
|
95
|
+
const absoluteOld = path.resolve(process.cwd(), target)
|
|
96
|
+
if (!absoluteOld.startsWith(oldPath)) continue
|
|
97
|
+
|
|
98
|
+
const absoluteNew = path.join(newPath, path.relative(oldPath, absoluteOld))
|
|
99
|
+
const exists = await fileExists(absoluteNew)
|
|
100
|
+
if (!exists) continue
|
|
101
|
+
|
|
102
|
+
const source = await resolveSource(absoluteNew)
|
|
103
|
+
if (!source) continue
|
|
104
|
+
|
|
105
|
+
await updateLinkRecord(absoluteOld, absoluteNew, source)
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
return watcher
|
|
110
|
+
}
|
package/src/scope.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ConfigScope } from './config'
|
|
2
|
+
|
|
3
|
+
export type ScopeResult = {
|
|
4
|
+
scope: ConfigScope
|
|
5
|
+
error: string | null
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const resolveScope = (input?: string): ScopeResult => {
|
|
9
|
+
if (!input) return { scope: 'global', error: null }
|
|
10
|
+
if (input === 'global') return { scope: 'global', error: null }
|
|
11
|
+
if (input === 'project') return { scope: 'project', error: null }
|
|
12
|
+
return { scope: 'global', error: 'scope 必须是 project 或 global' }
|
|
13
|
+
}
|