pmpt-cli 1.7.0 → 1.8.0
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 +6 -26
- package/dist/commands/browse.js +1 -1
- package/dist/commands/clone.js +14 -3
- package/dist/commands/diff.js +173 -0
- package/dist/commands/import.js +25 -7
- package/dist/commands/init.js +1 -1
- package/dist/commands/plan.js +1 -1
- package/dist/commands/publish.js +47 -2
- package/dist/commands/recover.js +222 -0
- package/dist/commands/status.js +40 -2
- package/dist/index.js +17 -2
- package/dist/lib/api.js +11 -0
- package/dist/lib/diff.js +143 -0
- package/dist/lib/pmptFile.js +29 -2
- package/dist/lib/quality.js +103 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -20,30 +20,9 @@ No coding required. No complex setup. Just answer 5 questions.
|
|
|
20
20
|
|
|
21
21
|
## Demo
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
┌ pmpt — Let's plan your product!
|
|
27
|
-
│
|
|
28
|
-
◆ What should we call your project?
|
|
29
|
-
│ my-budget-app
|
|
30
|
-
│
|
|
31
|
-
◆ What would you like to build with AI?
|
|
32
|
-
│ A personal budget tracking app for freelancers
|
|
33
|
-
│
|
|
34
|
-
◆ Any additional context AI should know? (optional)
|
|
35
|
-
│ Simple UI, mobile-friendly, works offline
|
|
36
|
-
│
|
|
37
|
-
◆ Key features to include?
|
|
38
|
-
│ Expense tracking; Income categories; Monthly reports; Export to CSV
|
|
39
|
-
│
|
|
40
|
-
◆ Preferred tech stack? (optional)
|
|
41
|
-
│ React, Node.js
|
|
42
|
-
│
|
|
43
|
-
└ Done! AI prompt copied to clipboard.
|
|
44
|
-
|
|
45
|
-
→ Open Claude / ChatGPT / Cursor → Ctrl+V → Start building!
|
|
46
|
-
```
|
|
23
|
+
<img src="./demo.gif" alt="pmpt plan demo" width="600" />
|
|
24
|
+
|
|
25
|
+
> Answer 5 questions → AI prompt auto-generated & copied to clipboard → Paste into Claude Code / Codex / Cursor → Start building!
|
|
47
26
|
|
|
48
27
|
---
|
|
49
28
|
|
|
@@ -69,7 +48,7 @@ pmpt init
|
|
|
69
48
|
# 3. Answer 5 questions → AI prompt auto-generated & copied
|
|
70
49
|
pmpt plan
|
|
71
50
|
|
|
72
|
-
# 4. Paste into Claude /
|
|
51
|
+
# 4. Paste into Claude Code / Codex / Cursor → Build your product!
|
|
73
52
|
|
|
74
53
|
# 5. Save your progress anytime
|
|
75
54
|
pmpt save
|
|
@@ -124,13 +103,14 @@ The generated prompt is **automatically copied to your clipboard**. Just paste i
|
|
|
124
103
|
| `pmpt squash v2 v5` | Merge versions v2–v5 into one |
|
|
125
104
|
| `pmpt export` | Export project as `.pmpt` file |
|
|
126
105
|
| `pmpt import <file>` | Import from `.pmpt` file |
|
|
106
|
+
| `pmpt recover` | Recover damaged pmpt.md via AI-generated prompt |
|
|
127
107
|
|
|
128
108
|
### Platform
|
|
129
109
|
|
|
130
110
|
| Command | Description |
|
|
131
111
|
|---------|-------------|
|
|
132
112
|
| `pmpt login` | Authenticate via GitHub (one-time) |
|
|
133
|
-
| `pmpt publish` | Publish your project
|
|
113
|
+
| `pmpt publish` | Publish your project (requires pmpt.md) |
|
|
134
114
|
| `pmpt edit` | Edit published project metadata (description, tags, category) |
|
|
135
115
|
| `pmpt unpublish` | Remove a published project from pmptwiki |
|
|
136
116
|
| `pmpt clone <slug>` | Clone and reproduce someone's project |
|
package/dist/commands/browse.js
CHANGED
|
@@ -64,7 +64,7 @@ export async function cmdBrowse() {
|
|
|
64
64
|
return;
|
|
65
65
|
}
|
|
66
66
|
if (action === 'url') {
|
|
67
|
-
const url = `https://pmptwiki.com/
|
|
67
|
+
const url = `https://pmptwiki.com/p/${project.slug}`;
|
|
68
68
|
p.log.info(`URL: ${url}`);
|
|
69
69
|
p.log.message(`Download: ${project.downloadUrl}`);
|
|
70
70
|
p.log.message(`\npmpt clone ${project.slug} — clone via terminal`);
|
package/dist/commands/clone.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import * as p from '@clack/prompts';
|
|
2
|
-
import { join, dirname } from 'path';
|
|
2
|
+
import { join, dirname, resolve, sep } from 'path';
|
|
3
3
|
import { existsSync, mkdirSync, writeFileSync, readdirSync } from 'fs';
|
|
4
4
|
import { isInitialized, getConfigDir, getHistoryDir, getDocsDir, initializeProject } from '../lib/config.js';
|
|
5
|
-
import { validatePmptFile } from '../lib/pmptFile.js';
|
|
6
|
-
import { fetchPmptFile } from '../lib/api.js';
|
|
5
|
+
import { validatePmptFile, isSafeFilename } from '../lib/pmptFile.js';
|
|
6
|
+
import { fetchPmptFile, trackClone } from '../lib/api.js';
|
|
7
7
|
/**
|
|
8
8
|
* Restore history from .pmpt data (shared with import command)
|
|
9
9
|
*/
|
|
@@ -15,7 +15,12 @@ export function restoreHistory(historyDir, history) {
|
|
|
15
15
|
const snapshotDir = join(historyDir, snapshotName);
|
|
16
16
|
mkdirSync(snapshotDir, { recursive: true });
|
|
17
17
|
for (const [filename, content] of Object.entries(version.files)) {
|
|
18
|
+
if (!isSafeFilename(filename))
|
|
19
|
+
continue; // skip unsafe filenames
|
|
18
20
|
const filePath = join(snapshotDir, filename);
|
|
21
|
+
// Double-check resolved path stays within snapshot dir
|
|
22
|
+
if (!resolve(filePath).startsWith(resolve(snapshotDir) + sep))
|
|
23
|
+
continue;
|
|
19
24
|
const fileDir = dirname(filePath);
|
|
20
25
|
if (fileDir !== snapshotDir) {
|
|
21
26
|
mkdirSync(fileDir, { recursive: true });
|
|
@@ -36,7 +41,11 @@ export function restoreHistory(historyDir, history) {
|
|
|
36
41
|
export function restoreDocs(docsDir, docs) {
|
|
37
42
|
mkdirSync(docsDir, { recursive: true });
|
|
38
43
|
for (const [filename, content] of Object.entries(docs)) {
|
|
44
|
+
if (!isSafeFilename(filename))
|
|
45
|
+
continue; // skip unsafe filenames
|
|
39
46
|
const filePath = join(docsDir, filename);
|
|
47
|
+
if (!resolve(filePath).startsWith(resolve(docsDir) + sep))
|
|
48
|
+
continue;
|
|
40
49
|
const fileDir = dirname(filePath);
|
|
41
50
|
if (fileDir !== docsDir) {
|
|
42
51
|
mkdirSync(fileDir, { recursive: true });
|
|
@@ -114,6 +123,8 @@ export async function cmdClone(slug) {
|
|
|
114
123
|
versionCount = readdirSync(historyDir).filter((d) => d.startsWith('v')).length;
|
|
115
124
|
}
|
|
116
125
|
importSpinner.stop('Restore complete!');
|
|
126
|
+
// Track clone event (fire-and-forget)
|
|
127
|
+
trackClone(slug);
|
|
117
128
|
p.note([
|
|
118
129
|
`Project: ${pmptData.meta.projectName}`,
|
|
119
130
|
`Versions: ${versionCount}`,
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import * as p from '@clack/prompts';
|
|
2
|
+
import { resolve, join } from 'path';
|
|
3
|
+
import { existsSync, readFileSync } from 'fs';
|
|
4
|
+
import { isInitialized, getDocsDir } from '../lib/config.js';
|
|
5
|
+
import { getAllSnapshots, resolveFullSnapshot } from '../lib/history.js';
|
|
6
|
+
import { diffSnapshots } from '../lib/diff.js';
|
|
7
|
+
import pc from 'picocolors';
|
|
8
|
+
import glob from 'fast-glob';
|
|
9
|
+
/** Read current .pmpt/docs/ as Record<filename, content>. */
|
|
10
|
+
function readWorkingCopy(projectPath) {
|
|
11
|
+
const docsDir = getDocsDir(projectPath);
|
|
12
|
+
const files = {};
|
|
13
|
+
if (!existsSync(docsDir))
|
|
14
|
+
return files;
|
|
15
|
+
const mdFiles = glob.sync('**/*.md', { cwd: docsDir });
|
|
16
|
+
for (const file of mdFiles) {
|
|
17
|
+
try {
|
|
18
|
+
files[file] = readFileSync(join(docsDir, file), 'utf-8');
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
// skip unreadable
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return files;
|
|
25
|
+
}
|
|
26
|
+
/** Format unified diff hunk header. */
|
|
27
|
+
function formatHunkHeader(hunk) {
|
|
28
|
+
const oldRange = hunk.oldCount === 1 ? `${hunk.oldStart}` : `${hunk.oldStart},${hunk.oldCount}`;
|
|
29
|
+
const newRange = hunk.newCount === 1 ? `${hunk.newStart}` : `${hunk.newStart},${hunk.newCount}`;
|
|
30
|
+
return `@@ -${oldRange} +${newRange} @@`;
|
|
31
|
+
}
|
|
32
|
+
/** Print one file's diff to stdout with colors. */
|
|
33
|
+
function printFileDiff(fd) {
|
|
34
|
+
console.log(pc.bold(`--- a/${fd.fileName}`));
|
|
35
|
+
console.log(pc.bold(`+++ b/${fd.fileName}`));
|
|
36
|
+
for (const hunk of fd.hunks) {
|
|
37
|
+
console.log(pc.cyan(formatHunkHeader(hunk)));
|
|
38
|
+
for (const line of hunk.lines) {
|
|
39
|
+
if (line.type === 'add')
|
|
40
|
+
console.log(pc.green(`+${line.content}`));
|
|
41
|
+
else if (line.type === 'remove')
|
|
42
|
+
console.log(pc.red(`-${line.content}`));
|
|
43
|
+
else
|
|
44
|
+
console.log(pc.dim(` ${line.content}`));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
console.log('');
|
|
48
|
+
}
|
|
49
|
+
/** Print summary statistics. */
|
|
50
|
+
function printSummary(diffs) {
|
|
51
|
+
const modified = diffs.filter(d => d.status === 'modified').length;
|
|
52
|
+
const added = diffs.filter(d => d.status === 'added').length;
|
|
53
|
+
const removed = diffs.filter(d => d.status === 'removed').length;
|
|
54
|
+
const parts = [];
|
|
55
|
+
if (modified > 0)
|
|
56
|
+
parts.push(`${modified} modified`);
|
|
57
|
+
if (added > 0)
|
|
58
|
+
parts.push(`${added} added`);
|
|
59
|
+
if (removed > 0)
|
|
60
|
+
parts.push(`${removed} removed`);
|
|
61
|
+
let additions = 0;
|
|
62
|
+
let deletions = 0;
|
|
63
|
+
for (const fd of diffs) {
|
|
64
|
+
for (const hunk of fd.hunks) {
|
|
65
|
+
for (const line of hunk.lines) {
|
|
66
|
+
if (line.type === 'add')
|
|
67
|
+
additions++;
|
|
68
|
+
if (line.type === 'remove')
|
|
69
|
+
deletions++;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
p.log.info(`${diffs.length} file(s) changed: ${parts.join(', ')}`);
|
|
74
|
+
p.log.info(`${pc.green(`+${additions}`)} additions, ${pc.red(`-${deletions}`)} deletions`);
|
|
75
|
+
}
|
|
76
|
+
export function cmdDiff(v1, v2, pathOrOptions, maybeOptions) {
|
|
77
|
+
// Commander passes args differently depending on how many positionals are given.
|
|
78
|
+
// pmpt diff v1 --file x → (v1, options)
|
|
79
|
+
// pmpt diff v1 v2 → (v1, v2, options)
|
|
80
|
+
// pmpt diff v1 v2 /path → (v1, v2, path, options)
|
|
81
|
+
let v2Str;
|
|
82
|
+
let path;
|
|
83
|
+
let options = {};
|
|
84
|
+
if (typeof v2 === 'object') {
|
|
85
|
+
options = v2;
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
v2Str = v2;
|
|
89
|
+
if (typeof pathOrOptions === 'object') {
|
|
90
|
+
options = pathOrOptions;
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
path = pathOrOptions;
|
|
94
|
+
if (maybeOptions)
|
|
95
|
+
options = maybeOptions;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const projectPath = path ? resolve(path) : process.cwd();
|
|
99
|
+
if (!isInitialized(projectPath)) {
|
|
100
|
+
p.log.error('Project not initialized. Run `pmpt init` first.');
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
const fromVersion = parseInt(v1.replace(/^v/, ''), 10);
|
|
104
|
+
if (isNaN(fromVersion)) {
|
|
105
|
+
p.log.error('Invalid version format. Use: pmpt diff v1 v2');
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
const diffAgainstWorking = v2Str === undefined;
|
|
109
|
+
let toVersion;
|
|
110
|
+
if (!diffAgainstWorking) {
|
|
111
|
+
toVersion = parseInt(v2Str.replace(/^v/, ''), 10);
|
|
112
|
+
if (isNaN(toVersion)) {
|
|
113
|
+
p.log.error('Invalid version format. Use: pmpt diff v1 v2');
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const snapshots = getAllSnapshots(projectPath);
|
|
118
|
+
if (snapshots.length === 0) {
|
|
119
|
+
p.log.error('No snapshots found.');
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
const fromIndex = snapshots.findIndex(s => s.version === fromVersion);
|
|
123
|
+
if (fromIndex === -1) {
|
|
124
|
+
p.log.error(`Version v${fromVersion} not found.`);
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
let toIndex;
|
|
128
|
+
if (!diffAgainstWorking) {
|
|
129
|
+
toIndex = snapshots.findIndex(s => s.version === toVersion);
|
|
130
|
+
if (toIndex === -1) {
|
|
131
|
+
p.log.error(`Version v${toVersion} not found.`);
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// Resolve file contents
|
|
136
|
+
const oldFiles = resolveFullSnapshot(snapshots, fromIndex);
|
|
137
|
+
const newFiles = diffAgainstWorking
|
|
138
|
+
? readWorkingCopy(projectPath)
|
|
139
|
+
: resolveFullSnapshot(snapshots, toIndex);
|
|
140
|
+
// Optional file filter
|
|
141
|
+
let filteredOld = oldFiles;
|
|
142
|
+
let filteredNew = newFiles;
|
|
143
|
+
if (options.file) {
|
|
144
|
+
filteredOld = oldFiles[options.file] !== undefined ? { [options.file]: oldFiles[options.file] } : {};
|
|
145
|
+
filteredNew = newFiles[options.file] !== undefined ? { [options.file]: newFiles[options.file] } : {};
|
|
146
|
+
if (Object.keys(filteredOld).length === 0 && Object.keys(filteredNew).length === 0) {
|
|
147
|
+
p.log.error(`File "${options.file}" not found in either version.`);
|
|
148
|
+
process.exit(1);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const diffs = diffSnapshots(filteredOld, filteredNew);
|
|
152
|
+
const targetLabel = diffAgainstWorking ? 'working copy' : `v${toVersion}`;
|
|
153
|
+
p.intro(`pmpt diff v${fromVersion} → ${targetLabel}`);
|
|
154
|
+
if (diffs.length === 0) {
|
|
155
|
+
p.log.info('No differences found.');
|
|
156
|
+
p.outro('');
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
// Changed files list
|
|
160
|
+
const fileList = diffs.map(d => {
|
|
161
|
+
const icon = d.status === 'added' ? pc.green('A')
|
|
162
|
+
: d.status === 'removed' ? pc.red('D')
|
|
163
|
+
: pc.yellow('M');
|
|
164
|
+
return ` ${icon} ${d.fileName}`;
|
|
165
|
+
}).join('\n');
|
|
166
|
+
p.note(fileList, 'Changed files');
|
|
167
|
+
console.log('');
|
|
168
|
+
for (const fd of diffs) {
|
|
169
|
+
printFileDiff(fd);
|
|
170
|
+
}
|
|
171
|
+
printSummary(diffs);
|
|
172
|
+
p.outro('');
|
|
173
|
+
}
|
package/dist/commands/import.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import * as p from '@clack/prompts';
|
|
2
|
-
import { resolve, join, dirname } from 'path';
|
|
3
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from 'fs';
|
|
2
|
+
import { resolve, join, dirname, sep } from 'path';
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync } from 'fs';
|
|
4
4
|
import { isInitialized, getConfigDir, getHistoryDir, getDocsDir, initializeProject } from '../lib/config.js';
|
|
5
|
-
import { validatePmptFile } from '../lib/pmptFile.js';
|
|
5
|
+
import { validatePmptFile, isSafeFilename } from '../lib/pmptFile.js';
|
|
6
6
|
/**
|
|
7
7
|
* Restore history from .pmpt file
|
|
8
8
|
*/
|
|
@@ -13,9 +13,13 @@ function restoreHistory(historyDir, history) {
|
|
|
13
13
|
const snapshotName = `v${version.version}-${timestamp}`;
|
|
14
14
|
const snapshotDir = join(historyDir, snapshotName);
|
|
15
15
|
mkdirSync(snapshotDir, { recursive: true });
|
|
16
|
-
// Write files
|
|
16
|
+
// Write files (with path traversal protection)
|
|
17
17
|
for (const [filename, content] of Object.entries(version.files)) {
|
|
18
|
+
if (!isSafeFilename(filename))
|
|
19
|
+
continue;
|
|
18
20
|
const filePath = join(snapshotDir, filename);
|
|
21
|
+
if (!resolve(filePath).startsWith(resolve(snapshotDir) + sep))
|
|
22
|
+
continue;
|
|
19
23
|
const fileDir = dirname(filePath);
|
|
20
24
|
if (fileDir !== snapshotDir) {
|
|
21
25
|
mkdirSync(fileDir, { recursive: true });
|
|
@@ -38,7 +42,11 @@ function restoreHistory(historyDir, history) {
|
|
|
38
42
|
function restoreDocs(docsDir, docs) {
|
|
39
43
|
mkdirSync(docsDir, { recursive: true });
|
|
40
44
|
for (const [filename, content] of Object.entries(docs)) {
|
|
45
|
+
if (!isSafeFilename(filename))
|
|
46
|
+
continue;
|
|
41
47
|
const filePath = join(docsDir, filename);
|
|
48
|
+
if (!resolve(filePath).startsWith(resolve(docsDir) + sep))
|
|
49
|
+
continue;
|
|
42
50
|
const fileDir = dirname(filePath);
|
|
43
51
|
if (fileDir !== docsDir) {
|
|
44
52
|
mkdirSync(fileDir, { recursive: true });
|
|
@@ -112,13 +120,23 @@ export async function cmdImport(pmptFile, options) {
|
|
|
112
120
|
}
|
|
113
121
|
const importSpinner = p.spinner();
|
|
114
122
|
importSpinner.start('Importing project...');
|
|
123
|
+
const pmptDir = getConfigDir(projectPath);
|
|
124
|
+
const historyDir = getHistoryDir(projectPath);
|
|
125
|
+
const docsDir = getDocsDir(projectPath);
|
|
126
|
+
// --force: clean existing history and docs before restoring
|
|
127
|
+
if (options?.force && isInitialized(projectPath)) {
|
|
128
|
+
if (existsSync(historyDir))
|
|
129
|
+
rmSync(historyDir, { recursive: true, force: true });
|
|
130
|
+
if (existsSync(docsDir))
|
|
131
|
+
rmSync(docsDir, { recursive: true, force: true });
|
|
132
|
+
const planPath = join(pmptDir, 'plan-progress.json');
|
|
133
|
+
if (existsSync(planPath))
|
|
134
|
+
rmSync(planPath, { force: true });
|
|
135
|
+
}
|
|
115
136
|
// Initialize project if not exists
|
|
116
137
|
if (!isInitialized(projectPath)) {
|
|
117
138
|
initializeProject(projectPath, { trackGit: true });
|
|
118
139
|
}
|
|
119
|
-
const pmptDir = getConfigDir(projectPath);
|
|
120
|
-
const historyDir = getHistoryDir(projectPath);
|
|
121
|
-
const docsDir = getDocsDir(projectPath);
|
|
122
140
|
// Restore history
|
|
123
141
|
restoreHistory(historyDir, pmptData.history);
|
|
124
142
|
// Restore docs
|
package/dist/commands/init.js
CHANGED
|
@@ -185,7 +185,7 @@ export async function cmdInit(path, options) {
|
|
|
185
185
|
` Location: ${planPath}`,
|
|
186
186
|
'',
|
|
187
187
|
`2. pmpt.md — AI prompt (THE IMPORTANT ONE!)`,
|
|
188
|
-
` Copy this to Claude/
|
|
188
|
+
` Copy this to Claude Code / Codex / Cursor`,
|
|
189
189
|
` Location: ${promptPath}`,
|
|
190
190
|
];
|
|
191
191
|
p.note(docExplanation.join('\n'), 'Auto-generated from project scan');
|
package/dist/commands/plan.js
CHANGED
|
@@ -160,7 +160,7 @@ export async function cmdPlan(path, options) {
|
|
|
160
160
|
` Location: ${planPath}`,
|
|
161
161
|
'',
|
|
162
162
|
`2. pmpt.md — AI prompt (THE IMPORTANT ONE!)`,
|
|
163
|
-
` • Copy this to Claude/
|
|
163
|
+
` • Copy this to Claude Code / Codex / Cursor`,
|
|
164
164
|
` • AI will help you build step by step`,
|
|
165
165
|
` • AI will update this doc as you progress`,
|
|
166
166
|
` Location: ${promptPath}`,
|
package/dist/commands/publish.js
CHANGED
|
@@ -7,6 +7,8 @@ import { getPlanProgress } from '../lib/plan.js';
|
|
|
7
7
|
import { createPmptFile } from '../lib/pmptFile.js';
|
|
8
8
|
import { loadAuth } from '../lib/auth.js';
|
|
9
9
|
import { publishProject, fetchProjects } from '../lib/api.js';
|
|
10
|
+
import { computeQuality } from '../lib/quality.js';
|
|
11
|
+
import pc from 'picocolors';
|
|
10
12
|
import glob from 'fast-glob';
|
|
11
13
|
import { join } from 'path';
|
|
12
14
|
function readDocsFolder(docsDir) {
|
|
@@ -22,7 +24,7 @@ function readDocsFolder(docsDir) {
|
|
|
22
24
|
}
|
|
23
25
|
return files;
|
|
24
26
|
}
|
|
25
|
-
export async function cmdPublish(path) {
|
|
27
|
+
export async function cmdPublish(path, options) {
|
|
26
28
|
const projectPath = path ? resolve(path) : process.cwd();
|
|
27
29
|
if (!isInitialized(projectPath)) {
|
|
28
30
|
p.log.error('Project not initialized. Run `pmpt init` first.');
|
|
@@ -42,6 +44,50 @@ export async function cmdPublish(path) {
|
|
|
42
44
|
p.outro('');
|
|
43
45
|
return;
|
|
44
46
|
}
|
|
47
|
+
// Validate pmpt.md exists and has content
|
|
48
|
+
const pmptMdPath = join(getDocsDir(projectPath), 'pmpt.md');
|
|
49
|
+
if (!existsSync(pmptMdPath)) {
|
|
50
|
+
p.log.error('pmpt.md not found. Run `pmpt plan` to generate it first.');
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
const pmptMdContent = readFileSync(pmptMdPath, 'utf-8').trim();
|
|
54
|
+
if (pmptMdContent.length === 0) {
|
|
55
|
+
p.log.error('pmpt.md is empty. Run `pmpt plan` to generate content.');
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
// Quality gate
|
|
59
|
+
const docsDir = getDocsDir(projectPath);
|
|
60
|
+
const trackedFiles = glob.sync('**/*.md', { cwd: docsDir });
|
|
61
|
+
const hasGit = snapshots.some(s => !!s.git);
|
|
62
|
+
const quality = computeQuality({
|
|
63
|
+
pmptMd: pmptMdContent,
|
|
64
|
+
planAnswers: planProgress?.answers ?? null,
|
|
65
|
+
versionCount: snapshots.length,
|
|
66
|
+
docFiles: trackedFiles,
|
|
67
|
+
hasGit,
|
|
68
|
+
});
|
|
69
|
+
const gradeColor = quality.grade === 'A' ? pc.green
|
|
70
|
+
: quality.grade === 'B' ? pc.blue
|
|
71
|
+
: quality.grade === 'C' ? pc.yellow
|
|
72
|
+
: pc.red;
|
|
73
|
+
const qLines = [`Score: ${gradeColor(`${quality.score}/100`)} (Grade ${gradeColor(quality.grade)})`];
|
|
74
|
+
for (const item of quality.details) {
|
|
75
|
+
const icon = item.score === item.maxScore ? pc.green('✓') : pc.red('✗');
|
|
76
|
+
qLines.push(`${icon} ${item.label.padEnd(20)} ${item.score}/${item.maxScore}`);
|
|
77
|
+
}
|
|
78
|
+
p.note(qLines.join('\n'), 'Quality Score');
|
|
79
|
+
if (!quality.passesMinimum) {
|
|
80
|
+
const tips = quality.details.filter(d => d.tip).map(d => ` → ${d.tip}`);
|
|
81
|
+
p.log.warn(`Quality score ${quality.score}/100 is below minimum (40).`);
|
|
82
|
+
if (tips.length > 0) {
|
|
83
|
+
p.log.info('How to improve:\n' + tips.join('\n'));
|
|
84
|
+
}
|
|
85
|
+
if (!options?.force) {
|
|
86
|
+
p.log.error('Use `pmpt publish --force` to publish anyway.');
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
p.log.warn('Publishing with --force despite low quality score.');
|
|
90
|
+
}
|
|
45
91
|
const projectName = planProgress?.answers?.projectName || basename(projectPath);
|
|
46
92
|
// Try to load existing published data for prefill
|
|
47
93
|
let existing;
|
|
@@ -117,7 +163,6 @@ export async function cmdPublish(path) {
|
|
|
117
163
|
files: resolveFullSnapshot(snapshots, i),
|
|
118
164
|
git: snapshot.git,
|
|
119
165
|
}));
|
|
120
|
-
const docsDir = getDocsDir(projectPath);
|
|
121
166
|
const docs = readDocsFolder(docsDir);
|
|
122
167
|
const meta = {
|
|
123
168
|
projectName,
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import * as p from '@clack/prompts';
|
|
2
|
+
import { resolve, basename } from 'path';
|
|
3
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { isInitialized, getDocsDir } from '../lib/config.js';
|
|
6
|
+
import { getAllSnapshots, resolveFileContent } from '../lib/history.js';
|
|
7
|
+
import { getPlanProgress } from '../lib/plan.js';
|
|
8
|
+
import { copyToClipboard } from '../lib/clipboard.js';
|
|
9
|
+
export async function cmdRecover(path) {
|
|
10
|
+
const projectPath = path ? resolve(path) : process.cwd();
|
|
11
|
+
if (!isInitialized(projectPath)) {
|
|
12
|
+
p.log.error('Project not initialized. Run `pmpt init` first.');
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
p.intro('pmpt recover');
|
|
16
|
+
const docsDir = getDocsDir(projectPath);
|
|
17
|
+
const pmptMdPath = join(docsDir, 'pmpt.md');
|
|
18
|
+
const planMdPath = join(docsDir, 'plan.md');
|
|
19
|
+
// Check current state
|
|
20
|
+
const currentExists = existsSync(pmptMdPath);
|
|
21
|
+
const currentContent = currentExists ? readFileSync(pmptMdPath, 'utf-8').trim() : '';
|
|
22
|
+
if (currentExists && currentContent.length > 100) {
|
|
23
|
+
const proceed = await p.confirm({
|
|
24
|
+
message: `pmpt.md exists (${currentContent.length} chars). Overwrite with recovered version?`,
|
|
25
|
+
initialValue: false,
|
|
26
|
+
});
|
|
27
|
+
if (p.isCancel(proceed) || !proceed) {
|
|
28
|
+
p.cancel('Cancelled');
|
|
29
|
+
process.exit(0);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// Gather all available context
|
|
33
|
+
const context = [];
|
|
34
|
+
// 1. Plan progress answers
|
|
35
|
+
const planProgress = getPlanProgress(projectPath);
|
|
36
|
+
if (planProgress?.answers) {
|
|
37
|
+
const a = planProgress.answers;
|
|
38
|
+
context.push('## Project Plan Answers');
|
|
39
|
+
if (a.projectName)
|
|
40
|
+
context.push(`- **Project Name**: ${a.projectName}`);
|
|
41
|
+
if (a.productIdea)
|
|
42
|
+
context.push(`- **Product Idea**: ${a.productIdea}`);
|
|
43
|
+
if (a.coreFeatures)
|
|
44
|
+
context.push(`- **Core Features**: ${a.coreFeatures}`);
|
|
45
|
+
if (a.techStack)
|
|
46
|
+
context.push(`- **Tech Stack**: ${a.techStack}`);
|
|
47
|
+
if (a.additionalContext)
|
|
48
|
+
context.push(`- **Additional Context**: ${a.additionalContext}`);
|
|
49
|
+
context.push('');
|
|
50
|
+
}
|
|
51
|
+
// 2. plan.md content
|
|
52
|
+
if (existsSync(planMdPath)) {
|
|
53
|
+
const planMd = readFileSync(planMdPath, 'utf-8').trim();
|
|
54
|
+
if (planMd) {
|
|
55
|
+
context.push('## Current plan.md');
|
|
56
|
+
context.push('```markdown');
|
|
57
|
+
context.push(planMd);
|
|
58
|
+
context.push('```');
|
|
59
|
+
context.push('');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// 3. Last known good pmpt.md from history
|
|
63
|
+
const snapshots = getAllSnapshots(projectPath);
|
|
64
|
+
let lastPmptMd = null;
|
|
65
|
+
let lastPmptVersion = 0;
|
|
66
|
+
if (snapshots.length > 0) {
|
|
67
|
+
for (let i = snapshots.length - 1; i >= 0; i--) {
|
|
68
|
+
const content = resolveFileContent(snapshots, i, 'pmpt.md');
|
|
69
|
+
if (content && content.trim().length > 50) {
|
|
70
|
+
lastPmptMd = content;
|
|
71
|
+
lastPmptVersion = snapshots[i].version;
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (lastPmptMd) {
|
|
77
|
+
context.push(`## Last Known pmpt.md (from v${lastPmptVersion})`);
|
|
78
|
+
context.push('```markdown');
|
|
79
|
+
context.push(lastPmptMd);
|
|
80
|
+
context.push('```');
|
|
81
|
+
context.push('');
|
|
82
|
+
}
|
|
83
|
+
// 4. package.json info
|
|
84
|
+
const pkgPath = join(projectPath, 'package.json');
|
|
85
|
+
if (existsSync(pkgPath)) {
|
|
86
|
+
try {
|
|
87
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
88
|
+
context.push('## package.json');
|
|
89
|
+
const fields = [];
|
|
90
|
+
if (pkg.name)
|
|
91
|
+
fields.push(`- **name**: ${pkg.name}`);
|
|
92
|
+
if (pkg.description)
|
|
93
|
+
fields.push(`- **description**: ${pkg.description}`);
|
|
94
|
+
if (pkg.dependencies)
|
|
95
|
+
fields.push(`- **dependencies**: ${Object.keys(pkg.dependencies).join(', ')}`);
|
|
96
|
+
if (pkg.devDependencies)
|
|
97
|
+
fields.push(`- **devDependencies**: ${Object.keys(pkg.devDependencies).join(', ')}`);
|
|
98
|
+
if (pkg.scripts)
|
|
99
|
+
fields.push(`- **scripts**: ${Object.keys(pkg.scripts).join(', ')}`);
|
|
100
|
+
context.push(fields.join('\n'));
|
|
101
|
+
context.push('');
|
|
102
|
+
}
|
|
103
|
+
catch { /* skip */ }
|
|
104
|
+
}
|
|
105
|
+
// 5. Directory structure
|
|
106
|
+
const dirEntries = readdirTopLevel(projectPath);
|
|
107
|
+
if (dirEntries.length > 0) {
|
|
108
|
+
context.push('## Project Structure');
|
|
109
|
+
context.push(dirEntries.join('\n'));
|
|
110
|
+
context.push('');
|
|
111
|
+
}
|
|
112
|
+
if (context.length === 0) {
|
|
113
|
+
p.log.error('No project context found. Nothing to recover from.');
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
// Show what we found
|
|
117
|
+
const sources = [];
|
|
118
|
+
if (planProgress?.answers)
|
|
119
|
+
sources.push('plan answers');
|
|
120
|
+
if (existsSync(planMdPath))
|
|
121
|
+
sources.push('plan.md');
|
|
122
|
+
if (lastPmptMd)
|
|
123
|
+
sources.push(`history v${lastPmptVersion}`);
|
|
124
|
+
if (existsSync(pkgPath))
|
|
125
|
+
sources.push('package.json');
|
|
126
|
+
p.log.info(`Found context: ${sources.join(', ')}`);
|
|
127
|
+
// Build recovery prompt
|
|
128
|
+
const projectName = planProgress?.answers?.projectName || basename(projectPath);
|
|
129
|
+
const prompt = `# pmpt.md Recovery Request
|
|
130
|
+
|
|
131
|
+
I need you to regenerate the \`.pmpt/docs/pmpt.md\` file for my project "${projectName}".
|
|
132
|
+
|
|
133
|
+
This file is the main AI prompt document — it contains the product development context that gets pasted into AI tools (Claude Code, Codex, Cursor, etc.) to continue development.
|
|
134
|
+
|
|
135
|
+
## Available Context
|
|
136
|
+
|
|
137
|
+
${context.join('\n')}
|
|
138
|
+
|
|
139
|
+
## Instructions
|
|
140
|
+
|
|
141
|
+
Based on the context above, regenerate \`.pmpt/docs/pmpt.md\` with the following structure:
|
|
142
|
+
|
|
143
|
+
\`\`\`
|
|
144
|
+
# {Project Name} — Product Development Request
|
|
145
|
+
|
|
146
|
+
## What I Want to Build
|
|
147
|
+
{describe the product idea}
|
|
148
|
+
|
|
149
|
+
## Key Features
|
|
150
|
+
- {feature 1}
|
|
151
|
+
- {feature 2}
|
|
152
|
+
...
|
|
153
|
+
|
|
154
|
+
## Tech Stack Preferences
|
|
155
|
+
{detected or specified tech stack}
|
|
156
|
+
|
|
157
|
+
## Additional Context
|
|
158
|
+
{any extra context}
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
Please help me build this product based on the requirements above.
|
|
163
|
+
|
|
164
|
+
1. First, review the requirements and ask if anything is unclear.
|
|
165
|
+
2. Propose a technical architecture.
|
|
166
|
+
3. Outline the implementation steps.
|
|
167
|
+
4. Start coding from the first step.
|
|
168
|
+
|
|
169
|
+
I'll confirm progress at each step before moving to the next.
|
|
170
|
+
|
|
171
|
+
## Documentation Rule
|
|
172
|
+
|
|
173
|
+
**Important:** Update this document (located at \`.pmpt/docs/pmpt.md\`) at these moments:
|
|
174
|
+
- When architecture or tech decisions are finalized
|
|
175
|
+
- When a feature is implemented (mark as done)
|
|
176
|
+
- When a development phase is completed
|
|
177
|
+
- When requirements change or new decisions are made
|
|
178
|
+
\`\`\`
|
|
179
|
+
|
|
180
|
+
${lastPmptMd ? 'Use the "Last Known pmpt.md" as the primary reference — update it rather than starting from scratch.' : 'Generate a fresh pmpt.md based on the available context.'}
|
|
181
|
+
|
|
182
|
+
Write the content directly to \`.pmpt/docs/pmpt.md\`. After writing, run \`pmpt save\` to create a snapshot.`;
|
|
183
|
+
// Copy to clipboard
|
|
184
|
+
const copied = copyToClipboard(prompt);
|
|
185
|
+
if (copied) {
|
|
186
|
+
p.log.success('Recovery prompt copied to clipboard!');
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
p.log.warn('Could not copy to clipboard. Prompt printed below:');
|
|
190
|
+
console.log('\n' + prompt + '\n');
|
|
191
|
+
}
|
|
192
|
+
p.note([
|
|
193
|
+
'1. Paste the prompt into your AI tool (Claude Code, Cursor, etc.)',
|
|
194
|
+
'2. The AI will regenerate .pmpt/docs/pmpt.md',
|
|
195
|
+
'3. Run `pmpt save` to snapshot the recovered file',
|
|
196
|
+
].join('\n'), 'Next Steps');
|
|
197
|
+
p.outro('');
|
|
198
|
+
}
|
|
199
|
+
function readdirTopLevel(projectPath) {
|
|
200
|
+
const ignore = new Set([
|
|
201
|
+
'node_modules', '.git', '.pmpt', 'dist', 'build', '.next',
|
|
202
|
+
'.nuxt', '.astro', '.vercel', '.cache', 'coverage', '.turbo',
|
|
203
|
+
]);
|
|
204
|
+
try {
|
|
205
|
+
const entries = readdirSync(projectPath, { encoding: 'utf-8' });
|
|
206
|
+
return entries
|
|
207
|
+
.filter(e => !ignore.has(e) && !e.startsWith('.'))
|
|
208
|
+
.slice(0, 30)
|
|
209
|
+
.map(e => {
|
|
210
|
+
try {
|
|
211
|
+
const stat = statSync(join(projectPath, e));
|
|
212
|
+
return stat.isDirectory() ? `${e}/` : e;
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
return e;
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
return [];
|
|
221
|
+
}
|
|
222
|
+
}
|
package/dist/commands/status.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import * as p from '@clack/prompts';
|
|
2
|
-
import { resolve } from 'path';
|
|
3
|
-
import {
|
|
2
|
+
import { resolve, join } from 'path';
|
|
3
|
+
import { readFileSync, existsSync } from 'fs';
|
|
4
|
+
import { isInitialized, loadConfig, getDocsDir } from '../lib/config.js';
|
|
4
5
|
import { getTrackedFiles, getAllSnapshots } from '../lib/history.js';
|
|
6
|
+
import { getPlanProgress } from '../lib/plan.js';
|
|
7
|
+
import { computeQuality } from '../lib/quality.js';
|
|
8
|
+
import pc from 'picocolors';
|
|
5
9
|
export function cmdStatus(path) {
|
|
6
10
|
const projectPath = path ? resolve(path) : process.cwd();
|
|
7
11
|
if (!isInitialized(projectPath)) {
|
|
@@ -31,5 +35,39 @@ export function cmdStatus(path) {
|
|
|
31
35
|
notes.push(' (none - start with pmpt plan)');
|
|
32
36
|
}
|
|
33
37
|
p.note(notes.join('\n'), 'Project Info');
|
|
38
|
+
// Quality Score
|
|
39
|
+
const docsDir = getDocsDir(projectPath);
|
|
40
|
+
const pmptMdPath = join(docsDir, 'pmpt.md');
|
|
41
|
+
const pmptMd = existsSync(pmptMdPath) ? readFileSync(pmptMdPath, 'utf-8') : null;
|
|
42
|
+
const planProgress = getPlanProgress(projectPath);
|
|
43
|
+
const hasGit = snapshots.some(s => !!s.git);
|
|
44
|
+
const quality = computeQuality({
|
|
45
|
+
pmptMd,
|
|
46
|
+
planAnswers: planProgress?.answers ?? null,
|
|
47
|
+
versionCount: snapshots.length,
|
|
48
|
+
docFiles: tracked,
|
|
49
|
+
hasGit,
|
|
50
|
+
});
|
|
51
|
+
const gradeColor = quality.grade === 'A' ? pc.green
|
|
52
|
+
: quality.grade === 'B' ? pc.blue
|
|
53
|
+
: quality.grade === 'C' ? pc.yellow
|
|
54
|
+
: pc.red;
|
|
55
|
+
const qLines = [
|
|
56
|
+
`Score: ${gradeColor(`${quality.score}/100`)} (Grade ${gradeColor(quality.grade)})`,
|
|
57
|
+
'',
|
|
58
|
+
];
|
|
59
|
+
for (const item of quality.details) {
|
|
60
|
+
const icon = item.score === item.maxScore ? pc.green('✓') : pc.red('✗');
|
|
61
|
+
const scoreStr = `${item.score}/${item.maxScore}`.padStart(5);
|
|
62
|
+
qLines.push(`${icon} ${item.label.padEnd(20)} ${scoreStr}`);
|
|
63
|
+
}
|
|
64
|
+
const tips = quality.details.filter(d => d.tip).map(d => d.tip);
|
|
65
|
+
if (tips.length > 0) {
|
|
66
|
+
qLines.push('');
|
|
67
|
+
for (const tip of tips) {
|
|
68
|
+
qLines.push(`${pc.dim('→')} ${tip}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
p.note(qLines.join('\n'), 'Quality Score');
|
|
34
72
|
p.outro('');
|
|
35
73
|
}
|
package/dist/index.js
CHANGED
|
@@ -15,6 +15,8 @@ import { cmdEdit } from './commands/edit.js';
|
|
|
15
15
|
import { cmdUnpublish } from './commands/unpublish.js';
|
|
16
16
|
import { cmdClone } from './commands/clone.js';
|
|
17
17
|
import { cmdBrowse } from './commands/browse.js';
|
|
18
|
+
import { cmdRecover } from './commands/recover.js';
|
|
19
|
+
import { cmdDiff } from './commands/diff.js';
|
|
18
20
|
const program = new Command();
|
|
19
21
|
program
|
|
20
22
|
.name('pmpt')
|
|
@@ -27,6 +29,8 @@ Examples:
|
|
|
27
29
|
$ pmpt save Save snapshot of docs folder
|
|
28
30
|
$ pmpt watch Auto-detect file changes
|
|
29
31
|
$ pmpt history View version history
|
|
32
|
+
$ pmpt diff v1 v2 Compare two versions
|
|
33
|
+
$ pmpt diff v3 Compare v3 to working copy
|
|
30
34
|
$ pmpt squash v2 v5 Merge versions v2-v5 into v2
|
|
31
35
|
$ pmpt export Export as .pmpt file (single JSON)
|
|
32
36
|
$ pmpt import <file.pmpt> Import from .pmpt file
|
|
@@ -34,6 +38,7 @@ Examples:
|
|
|
34
38
|
$ pmpt publish Publish project to pmptwiki
|
|
35
39
|
$ pmpt clone <slug> Clone a project from pmptwiki
|
|
36
40
|
$ pmpt browse Browse published projects
|
|
41
|
+
$ pmpt recover Recover damaged pmpt.md via AI
|
|
37
42
|
|
|
38
43
|
Documentation: https://pmptwiki.com
|
|
39
44
|
`);
|
|
@@ -60,18 +65,23 @@ program
|
|
|
60
65
|
.description('View saved version history')
|
|
61
66
|
.option('-c, --compact', 'Show compact history (hide small changes)')
|
|
62
67
|
.action(cmdHistory);
|
|
68
|
+
program
|
|
69
|
+
.command('diff <v1> [v2] [path]')
|
|
70
|
+
.description('Compare two versions (or version vs working copy)')
|
|
71
|
+
.option('-f, --file <name>', 'Compare specific file only')
|
|
72
|
+
.action(cmdDiff);
|
|
63
73
|
program
|
|
64
74
|
.command('squash <from> <to> [path]')
|
|
65
75
|
.description('Squash multiple versions into one (e.g., pmpt squash v2 v5)')
|
|
66
76
|
.action(cmdSquash);
|
|
67
77
|
program
|
|
68
78
|
.command('export [path]')
|
|
69
|
-
.description('Export project history as a shareable
|
|
79
|
+
.description('Export project history as a shareable .pmpt file')
|
|
70
80
|
.option('-o, --output <file>', 'Output file path')
|
|
71
81
|
.action(cmdExport);
|
|
72
82
|
program
|
|
73
83
|
.command('import <file>')
|
|
74
|
-
.description('Import project from
|
|
84
|
+
.description('Import project from .pmpt file')
|
|
75
85
|
.option('-f, --force', 'Overwrite existing project')
|
|
76
86
|
.action(cmdImport);
|
|
77
87
|
program
|
|
@@ -95,6 +105,7 @@ program
|
|
|
95
105
|
program
|
|
96
106
|
.command('publish [path]')
|
|
97
107
|
.description('Publish project to pmptwiki platform')
|
|
108
|
+
.option('--force', 'Publish even if quality score is below minimum')
|
|
98
109
|
.action(cmdPublish);
|
|
99
110
|
program
|
|
100
111
|
.command('edit')
|
|
@@ -112,4 +123,8 @@ program
|
|
|
112
123
|
.command('browse')
|
|
113
124
|
.description('Browse and search published projects')
|
|
114
125
|
.action(cmdBrowse);
|
|
126
|
+
program
|
|
127
|
+
.command('recover [path]')
|
|
128
|
+
.description('Generate a recovery prompt to regenerate pmpt.md via AI')
|
|
129
|
+
.action(cmdRecover);
|
|
115
130
|
program.parse();
|
package/dist/lib/api.js
CHANGED
|
@@ -81,3 +81,14 @@ export async function fetchPmptFile(slug) {
|
|
|
81
81
|
}
|
|
82
82
|
return res.text();
|
|
83
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* Fire-and-forget clone tracking ping.
|
|
86
|
+
* Never throws — failures are silently ignored.
|
|
87
|
+
*/
|
|
88
|
+
export function trackClone(slug) {
|
|
89
|
+
fetch(`${API_BASE}/metrics/clone`, {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
headers: { 'Content-Type': 'application/json' },
|
|
92
|
+
body: JSON.stringify({ slug }),
|
|
93
|
+
}).catch(() => { });
|
|
94
|
+
}
|
package/dist/lib/diff.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple unified diff implementation using LCS (Longest Common Subsequence).
|
|
3
|
+
* Pure functions — no file I/O, no UI.
|
|
4
|
+
*/
|
|
5
|
+
/** LCS DP table — O(n*m), fine for markdown files (<500 lines). */
|
|
6
|
+
function lcsTable(a, b) {
|
|
7
|
+
const m = a.length;
|
|
8
|
+
const n = b.length;
|
|
9
|
+
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
|
10
|
+
for (let i = 1; i <= m; i++) {
|
|
11
|
+
for (let j = 1; j <= n; j++) {
|
|
12
|
+
if (a[i - 1] === b[j - 1]) {
|
|
13
|
+
dp[i][j] = dp[i - 1][j - 1] + 1;
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return dp;
|
|
21
|
+
}
|
|
22
|
+
/** Backtrack LCS table → ordered DiffLine array. */
|
|
23
|
+
function backtrack(dp, a, b) {
|
|
24
|
+
const result = [];
|
|
25
|
+
let i = a.length;
|
|
26
|
+
let j = b.length;
|
|
27
|
+
while (i > 0 || j > 0) {
|
|
28
|
+
if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {
|
|
29
|
+
result.push({ type: 'context', content: a[i - 1] });
|
|
30
|
+
i--;
|
|
31
|
+
j--;
|
|
32
|
+
}
|
|
33
|
+
else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
|
|
34
|
+
result.push({ type: 'add', content: b[j - 1] });
|
|
35
|
+
j--;
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
result.push({ type: 'remove', content: a[i - 1] });
|
|
39
|
+
i--;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return result.reverse();
|
|
43
|
+
}
|
|
44
|
+
/** Build a DiffHunk from a slice of diff lines. */
|
|
45
|
+
function buildHunk(lines, start, end) {
|
|
46
|
+
const hunkLines = lines.slice(start, end + 1);
|
|
47
|
+
let oldLine = 1;
|
|
48
|
+
let newLine = 1;
|
|
49
|
+
for (let i = 0; i < start; i++) {
|
|
50
|
+
if (lines[i].type === 'context' || lines[i].type === 'remove')
|
|
51
|
+
oldLine++;
|
|
52
|
+
if (lines[i].type === 'context' || lines[i].type === 'add')
|
|
53
|
+
newLine++;
|
|
54
|
+
}
|
|
55
|
+
let oldCount = 0;
|
|
56
|
+
let newCount = 0;
|
|
57
|
+
for (const line of hunkLines) {
|
|
58
|
+
if (line.type === 'context' || line.type === 'remove')
|
|
59
|
+
oldCount++;
|
|
60
|
+
if (line.type === 'context' || line.type === 'add')
|
|
61
|
+
newCount++;
|
|
62
|
+
}
|
|
63
|
+
return { oldStart: oldLine, oldCount, newStart: newLine, newCount, lines: hunkLines };
|
|
64
|
+
}
|
|
65
|
+
/** Group diff lines into hunks with context (default 3 lines, like git). */
|
|
66
|
+
function groupIntoHunks(lines, contextLines = 3) {
|
|
67
|
+
const changeIndices = [];
|
|
68
|
+
for (let i = 0; i < lines.length; i++) {
|
|
69
|
+
if (lines[i].type !== 'context')
|
|
70
|
+
changeIndices.push(i);
|
|
71
|
+
}
|
|
72
|
+
if (changeIndices.length === 0)
|
|
73
|
+
return [];
|
|
74
|
+
const hunks = [];
|
|
75
|
+
let hunkStart = Math.max(0, changeIndices[0] - contextLines);
|
|
76
|
+
let hunkEnd = Math.min(lines.length - 1, changeIndices[0] + contextLines);
|
|
77
|
+
for (let k = 1; k < changeIndices.length; k++) {
|
|
78
|
+
const nextStart = Math.max(0, changeIndices[k] - contextLines);
|
|
79
|
+
const nextEnd = Math.min(lines.length - 1, changeIndices[k] + contextLines);
|
|
80
|
+
if (nextStart <= hunkEnd + 1) {
|
|
81
|
+
hunkEnd = nextEnd;
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
hunks.push(buildHunk(lines, hunkStart, hunkEnd));
|
|
85
|
+
hunkStart = nextStart;
|
|
86
|
+
hunkEnd = nextEnd;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
hunks.push(buildHunk(lines, hunkStart, hunkEnd));
|
|
90
|
+
return hunks;
|
|
91
|
+
}
|
|
92
|
+
/** Normalize trailing newline from split. */
|
|
93
|
+
function splitLines(content) {
|
|
94
|
+
const lines = content.split('\n');
|
|
95
|
+
if (lines.length > 0 && lines[lines.length - 1] === '')
|
|
96
|
+
lines.pop();
|
|
97
|
+
return lines;
|
|
98
|
+
}
|
|
99
|
+
/** Compute unified diff hunks between two strings. */
|
|
100
|
+
export function computeDiff(oldContent, newContent) {
|
|
101
|
+
const oldLines = splitLines(oldContent);
|
|
102
|
+
const newLines = splitLines(newContent);
|
|
103
|
+
const dp = lcsTable(oldLines, newLines);
|
|
104
|
+
const diffLines = backtrack(dp, oldLines, newLines);
|
|
105
|
+
return groupIntoHunks(diffLines);
|
|
106
|
+
}
|
|
107
|
+
/** Diff a single file between two versions. */
|
|
108
|
+
export function diffFile(fileName, oldContent, newContent) {
|
|
109
|
+
if (oldContent === null && newContent === null) {
|
|
110
|
+
return { fileName, status: 'unchanged', hunks: [] };
|
|
111
|
+
}
|
|
112
|
+
if (oldContent === null) {
|
|
113
|
+
const lines = splitLines(newContent).map(l => ({ type: 'add', content: l }));
|
|
114
|
+
return {
|
|
115
|
+
fileName,
|
|
116
|
+
status: 'added',
|
|
117
|
+
hunks: lines.length > 0 ? [{ oldStart: 0, oldCount: 0, newStart: 1, newCount: lines.length, lines }] : [],
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
if (newContent === null) {
|
|
121
|
+
const lines = splitLines(oldContent).map(l => ({ type: 'remove', content: l }));
|
|
122
|
+
return {
|
|
123
|
+
fileName,
|
|
124
|
+
status: 'removed',
|
|
125
|
+
hunks: lines.length > 0 ? [{ oldStart: 1, oldCount: lines.length, newStart: 0, newCount: 0, lines }] : [],
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
if (oldContent === newContent) {
|
|
129
|
+
return { fileName, status: 'unchanged', hunks: [] };
|
|
130
|
+
}
|
|
131
|
+
return { fileName, status: 'modified', hunks: computeDiff(oldContent, newContent) };
|
|
132
|
+
}
|
|
133
|
+
/** Diff all files between two snapshots (Record<filename, content>). */
|
|
134
|
+
export function diffSnapshots(oldFiles, newFiles) {
|
|
135
|
+
const allNames = new Set([...Object.keys(oldFiles), ...Object.keys(newFiles)]);
|
|
136
|
+
const diffs = [];
|
|
137
|
+
for (const name of [...allNames].sort()) {
|
|
138
|
+
const fd = diffFile(name, oldFiles[name] ?? null, newFiles[name] ?? null);
|
|
139
|
+
if (fd.status !== 'unchanged')
|
|
140
|
+
diffs.push(fd);
|
|
141
|
+
}
|
|
142
|
+
return diffs;
|
|
143
|
+
}
|
package/dist/lib/pmptFile.js
CHANGED
|
@@ -4,9 +4,36 @@
|
|
|
4
4
|
* Single JSON file format for sharing pmpt projects.
|
|
5
5
|
*/
|
|
6
6
|
import { z } from 'zod';
|
|
7
|
+
import { resolve, relative } from 'path';
|
|
7
8
|
// Schema version
|
|
8
9
|
export const SCHEMA_VERSION = '1.0';
|
|
9
10
|
export const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
|
11
|
+
/**
|
|
12
|
+
* Validate that a filename is safe (no path traversal).
|
|
13
|
+
* Rejects absolute paths, ".." components, and backslash paths.
|
|
14
|
+
*/
|
|
15
|
+
export function isSafeFilename(filename) {
|
|
16
|
+
if (!filename || filename.length === 0)
|
|
17
|
+
return false;
|
|
18
|
+
// Reject absolute paths and backslashes
|
|
19
|
+
if (/^[/\\]/.test(filename) || /\\/.test(filename))
|
|
20
|
+
return false;
|
|
21
|
+
// Reject .. path components
|
|
22
|
+
const parts = filename.split('/');
|
|
23
|
+
if (parts.some(p => p === '..' || p === '.'))
|
|
24
|
+
return false;
|
|
25
|
+
// Double check: resolve and verify it stays within parent
|
|
26
|
+
const base = '/safe';
|
|
27
|
+
const resolved = resolve(base, filename);
|
|
28
|
+
const rel = relative(base, resolved);
|
|
29
|
+
if (rel.startsWith('..') || resolve(rel) !== resolve(base, rel))
|
|
30
|
+
return false;
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
// Safe filename zod refinement
|
|
34
|
+
const safeFilenameKey = z.string().refine(isSafeFilename, {
|
|
35
|
+
message: 'Unsafe filename detected (path traversal)',
|
|
36
|
+
});
|
|
10
37
|
// Git info schema
|
|
11
38
|
const GitInfoSchema = z.object({
|
|
12
39
|
commit: z.string(),
|
|
@@ -20,7 +47,7 @@ const VersionSchema = z.object({
|
|
|
20
47
|
version: z.number().min(1),
|
|
21
48
|
timestamp: z.string(),
|
|
22
49
|
summary: z.string().optional(),
|
|
23
|
-
files: z.record(
|
|
50
|
+
files: z.record(safeFilenameKey, z.string()), // filename -> content
|
|
24
51
|
git: GitInfoSchema,
|
|
25
52
|
});
|
|
26
53
|
// Plan answers schema
|
|
@@ -46,7 +73,7 @@ export const PmptFileSchema = z.object({
|
|
|
46
73
|
guide: z.string().optional(),
|
|
47
74
|
meta: MetaSchema,
|
|
48
75
|
plan: PlanSchema,
|
|
49
|
-
docs: z.record(
|
|
76
|
+
docs: z.record(safeFilenameKey, z.string()).optional(), // current docs
|
|
50
77
|
history: z.array(VersionSchema),
|
|
51
78
|
});
|
|
52
79
|
/**
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project quality score calculation.
|
|
3
|
+
* Pure functions — no I/O, reusable in CLI and API.
|
|
4
|
+
*/
|
|
5
|
+
const MIN_PUBLISH_SCORE = 40;
|
|
6
|
+
export function computeQuality(data) {
|
|
7
|
+
const details = [];
|
|
8
|
+
// 1. pmpt.md content (30 points)
|
|
9
|
+
{
|
|
10
|
+
let score = 0;
|
|
11
|
+
let tip;
|
|
12
|
+
const len = data.pmptMd?.trim().length ?? 0;
|
|
13
|
+
if (len > 0)
|
|
14
|
+
score += 10;
|
|
15
|
+
if (len >= 200)
|
|
16
|
+
score += 10;
|
|
17
|
+
if (len >= 500)
|
|
18
|
+
score += 10;
|
|
19
|
+
if (score < 30) {
|
|
20
|
+
tip = len === 0
|
|
21
|
+
? 'Run `pmpt plan` to generate pmpt.md'
|
|
22
|
+
: `pmpt.md is ${len} chars — expand to 500+ for full score`;
|
|
23
|
+
}
|
|
24
|
+
details.push({ label: 'pmpt.md content', score, maxScore: 30, tip });
|
|
25
|
+
}
|
|
26
|
+
// 2. Plan completeness (25 points)
|
|
27
|
+
{
|
|
28
|
+
let score = 0;
|
|
29
|
+
let tip;
|
|
30
|
+
if (data.planAnswers?.productIdea?.trim())
|
|
31
|
+
score += 10;
|
|
32
|
+
if (data.planAnswers?.coreFeatures?.trim())
|
|
33
|
+
score += 10;
|
|
34
|
+
if (data.planAnswers?.techStack?.trim())
|
|
35
|
+
score += 5;
|
|
36
|
+
if (score < 25) {
|
|
37
|
+
const missing = [];
|
|
38
|
+
if (!data.planAnswers?.productIdea?.trim())
|
|
39
|
+
missing.push('product idea');
|
|
40
|
+
if (!data.planAnswers?.coreFeatures?.trim())
|
|
41
|
+
missing.push('core features');
|
|
42
|
+
if (!data.planAnswers?.techStack?.trim())
|
|
43
|
+
missing.push('tech stack');
|
|
44
|
+
tip = `Complete plan: add ${missing.join(', ')}`;
|
|
45
|
+
}
|
|
46
|
+
details.push({ label: 'Plan completeness', score, maxScore: 25, tip });
|
|
47
|
+
}
|
|
48
|
+
// 3. Version history (20 points)
|
|
49
|
+
{
|
|
50
|
+
let score = 0;
|
|
51
|
+
let tip;
|
|
52
|
+
if (data.versionCount >= 2)
|
|
53
|
+
score += 10;
|
|
54
|
+
if (data.versionCount >= 3)
|
|
55
|
+
score += 10;
|
|
56
|
+
if (score < 20) {
|
|
57
|
+
tip = data.versionCount < 2
|
|
58
|
+
? 'Save more versions with `pmpt save`'
|
|
59
|
+
: 'One more version for full score';
|
|
60
|
+
}
|
|
61
|
+
details.push({ label: 'Version history', score, maxScore: 20, tip });
|
|
62
|
+
}
|
|
63
|
+
// 4. Documentation files (15 points)
|
|
64
|
+
{
|
|
65
|
+
let score = 0;
|
|
66
|
+
let tip;
|
|
67
|
+
if (data.docFiles.includes('plan.md'))
|
|
68
|
+
score += 5;
|
|
69
|
+
if (data.docFiles.includes('pmpt.md'))
|
|
70
|
+
score += 5;
|
|
71
|
+
if (data.docFiles.length > 2)
|
|
72
|
+
score += 5;
|
|
73
|
+
if (score < 15) {
|
|
74
|
+
const missing = [];
|
|
75
|
+
if (!data.docFiles.includes('plan.md'))
|
|
76
|
+
missing.push('plan.md');
|
|
77
|
+
if (!data.docFiles.includes('pmpt.md'))
|
|
78
|
+
missing.push('pmpt.md');
|
|
79
|
+
if (data.docFiles.length <= 2)
|
|
80
|
+
missing.push('additional docs');
|
|
81
|
+
tip = `Add: ${missing.join(', ')}`;
|
|
82
|
+
}
|
|
83
|
+
details.push({ label: 'Documentation', score, maxScore: 15, tip });
|
|
84
|
+
}
|
|
85
|
+
// 5. Git integration (10 points)
|
|
86
|
+
{
|
|
87
|
+
let score = 0;
|
|
88
|
+
let tip;
|
|
89
|
+
if (data.hasGit)
|
|
90
|
+
score = 10;
|
|
91
|
+
else
|
|
92
|
+
tip = 'Initialize git repo for commit tracking';
|
|
93
|
+
details.push({ label: 'Git integration', score, maxScore: 10, tip });
|
|
94
|
+
}
|
|
95
|
+
const total = details.reduce((sum, d) => sum + d.score, 0);
|
|
96
|
+
const grade = total >= 80 ? 'A' : total >= 60 ? 'B' : total >= 40 ? 'C' : total >= 20 ? 'D' : 'F';
|
|
97
|
+
return {
|
|
98
|
+
score: total,
|
|
99
|
+
details,
|
|
100
|
+
grade,
|
|
101
|
+
passesMinimum: total >= MIN_PUBLISH_SCORE,
|
|
102
|
+
};
|
|
103
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmpt-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "Record and share your AI-driven product development journey",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"commander": "^12.0.0",
|
|
40
40
|
"fast-glob": "^3.3.0",
|
|
41
41
|
"open": "^11.0.0",
|
|
42
|
+
"picocolors": "^1.0.0",
|
|
42
43
|
"zod": "^3.22.0"
|
|
43
44
|
},
|
|
44
45
|
"devDependencies": {
|