@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/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
+ }