vibefast-cli 0.1.4 → 0.2.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/AUTO-DETECT-DEPS.md +607 -0
- package/CHANGELOG.md +86 -0
- package/FINAL-PACKAGE-STRATEGY.md +583 -0
- package/FINAL-SIMPLE-PLAN.md +487 -0
- package/FLOW-DIAGRAM.md +1629 -0
- package/GOTCHAS-AND-RISKS.md +801 -0
- package/IMPLEMENTATION-COMPLETE.md +477 -0
- package/IMPLEMENTATION-PLAN.md +1360 -0
- package/PRE-PUBLISH-CHECKLIST.md +558 -0
- package/PRODUCTION-READINESS.md +684 -0
- package/PRODUCTION-TEST-RESULTS.md +465 -0
- package/README.md +73 -7
- package/READY-TO-PUBLISH.md +419 -0
- package/SIMPLIFIED-PLAN.md +578 -0
- package/TEST-SUMMARY.md +261 -0
- package/USER-MODIFICATIONS.md +448 -0
- package/cloudflare-worker/worker.js +26 -6
- package/dist/commands/add.d.ts.map +1 -1
- package/dist/commands/add.js +192 -15
- package/dist/commands/add.js.map +1 -1
- package/dist/commands/checklist.d.ts +3 -0
- package/dist/commands/checklist.d.ts.map +1 -0
- package/dist/commands/checklist.js +64 -0
- package/dist/commands/checklist.js.map +1 -0
- package/dist/commands/remove.d.ts.map +1 -1
- package/dist/commands/remove.js +85 -2
- package/dist/commands/remove.js.map +1 -1
- package/dist/commands/status.d.ts +3 -0
- package/dist/commands/status.d.ts.map +1 -0
- package/dist/commands/status.js +40 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/core/__tests__/fsx.test.d.ts +2 -0
- package/dist/core/__tests__/fsx.test.d.ts.map +1 -0
- package/dist/core/__tests__/fsx.test.js +79 -0
- package/dist/core/__tests__/fsx.test.js.map +1 -0
- package/dist/core/__tests__/hash.test.d.ts +2 -0
- package/dist/core/__tests__/hash.test.d.ts.map +1 -0
- package/dist/core/__tests__/hash.test.js +84 -0
- package/dist/core/__tests__/hash.test.js.map +1 -0
- package/dist/core/__tests__/journal.test.js +65 -0
- package/dist/core/__tests__/journal.test.js.map +1 -1
- package/dist/core/__tests__/prompt.test.d.ts +2 -0
- package/dist/core/__tests__/prompt.test.d.ts.map +1 -0
- package/dist/core/__tests__/prompt.test.js +56 -0
- package/dist/core/__tests__/prompt.test.js.map +1 -0
- package/dist/core/fsx.d.ts +7 -1
- package/dist/core/fsx.d.ts.map +1 -1
- package/dist/core/fsx.js +18 -3
- package/dist/core/fsx.js.map +1 -1
- package/dist/core/hash.d.ts +13 -0
- package/dist/core/hash.d.ts.map +1 -0
- package/dist/core/hash.js +69 -0
- package/dist/core/hash.js.map +1 -0
- package/dist/core/journal.d.ts +10 -1
- package/dist/core/journal.d.ts.map +1 -1
- package/dist/core/journal.js +23 -1
- package/dist/core/journal.js.map +1 -1
- package/dist/core/prompt.d.ts +11 -0
- package/dist/core/prompt.d.ts.map +1 -0
- package/dist/core/prompt.js +34 -0
- package/dist/core/prompt.js.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/package.json +3 -1
- package/src/commands/add.ts +234 -16
- package/src/commands/checklist.ts +71 -0
- package/src/commands/remove.ts +105 -3
- package/src/commands/status.ts +47 -0
- package/src/core/__tests__/fsx.test.ts +101 -0
- package/src/core/__tests__/hash.test.ts +112 -0
- package/src/core/__tests__/journal.test.ts +76 -0
- package/src/core/__tests__/prompt.test.ts +72 -0
- package/src/core/fsx.ts +38 -5
- package/src/core/hash.ts +84 -0
- package/src/core/journal.ts +40 -2
- package/src/core/prompt.ts +40 -0
- package/src/index.ts +4 -0
- package/text.md +27 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { copyTree, exists } from '../fsx.js';
|
|
3
|
+
import { writeFile, mkdir, rm } from 'fs/promises';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { tmpdir } from 'os';
|
|
6
|
+
|
|
7
|
+
describe('fsx', () => {
|
|
8
|
+
let testDir: string;
|
|
9
|
+
let srcDir: string;
|
|
10
|
+
let destDir: string;
|
|
11
|
+
|
|
12
|
+
beforeEach(async () => {
|
|
13
|
+
testDir = join(tmpdir(), `vibefast-test-${Date.now()}`);
|
|
14
|
+
srcDir = join(testDir, 'src');
|
|
15
|
+
destDir = join(testDir, 'dest');
|
|
16
|
+
|
|
17
|
+
await mkdir(srcDir, { recursive: true });
|
|
18
|
+
await mkdir(destDir, { recursive: true });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(async () => {
|
|
22
|
+
await rm(testDir, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('copyTree', () => {
|
|
26
|
+
it('should copy files successfully', async () => {
|
|
27
|
+
const file1 = join(srcDir, 'file1.txt');
|
|
28
|
+
const file2 = join(srcDir, 'file2.txt');
|
|
29
|
+
|
|
30
|
+
await writeFile(file1, 'content 1');
|
|
31
|
+
await writeFile(file2, 'content 2');
|
|
32
|
+
|
|
33
|
+
const result = await copyTree(srcDir, destDir);
|
|
34
|
+
|
|
35
|
+
expect(result.files).toHaveLength(2);
|
|
36
|
+
expect(result.conflicts).toHaveLength(0);
|
|
37
|
+
expect(result.skipped).toHaveLength(0);
|
|
38
|
+
|
|
39
|
+
expect(await exists(join(destDir, 'file1.txt'))).toBe(true);
|
|
40
|
+
expect(await exists(join(destDir, 'file2.txt'))).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should detect conflicts', async () => {
|
|
44
|
+
const srcFile = join(srcDir, 'file.txt');
|
|
45
|
+
const destFile = join(destDir, 'file.txt');
|
|
46
|
+
|
|
47
|
+
await writeFile(srcFile, 'new content');
|
|
48
|
+
await writeFile(destFile, 'existing content');
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
await copyTree(srcDir, destDir);
|
|
52
|
+
expect.fail('Should have thrown error');
|
|
53
|
+
} catch (error: any) {
|
|
54
|
+
expect(error.message).toContain('File exists');
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should overwrite with force flag', async () => {
|
|
59
|
+
// Create source file
|
|
60
|
+
await writeFile(join(srcDir, 'file.txt'), 'new content');
|
|
61
|
+
|
|
62
|
+
// Create existing destination file
|
|
63
|
+
await writeFile(join(destDir, 'file.txt'), 'existing content');
|
|
64
|
+
|
|
65
|
+
const result = await copyTree(srcDir, destDir, { force: true });
|
|
66
|
+
|
|
67
|
+
expect(result.files).toHaveLength(1);
|
|
68
|
+
expect(result.conflicts).toHaveLength(1);
|
|
69
|
+
expect(result.skipped).toHaveLength(0);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should handle dry-run mode', async () => {
|
|
73
|
+
const file1 = join(srcDir, 'file1.txt');
|
|
74
|
+
await writeFile(file1, 'content');
|
|
75
|
+
|
|
76
|
+
const result = await copyTree(srcDir, destDir, { dryRun: true });
|
|
77
|
+
|
|
78
|
+
expect(result.files).toHaveLength(1);
|
|
79
|
+
expect(await exists(join(destDir, 'file1.txt'))).toBe(false); // Not actually copied
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should copy nested directories', async () => {
|
|
83
|
+
const nestedDir = join(srcDir, 'nested');
|
|
84
|
+
await mkdir(nestedDir);
|
|
85
|
+
await writeFile(join(nestedDir, 'file.txt'), 'content');
|
|
86
|
+
|
|
87
|
+
const result = await copyTree(srcDir, destDir);
|
|
88
|
+
|
|
89
|
+
expect(result.files).toHaveLength(1);
|
|
90
|
+
expect(await exists(join(destDir, 'nested', 'file.txt'))).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should handle empty directory', async () => {
|
|
94
|
+
const result = await copyTree(srcDir, destDir);
|
|
95
|
+
|
|
96
|
+
expect(result.files).toHaveLength(0);
|
|
97
|
+
expect(result.conflicts).toHaveLength(0);
|
|
98
|
+
expect(result.skipped).toHaveLength(0);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { hashFile, hashFiles } from '../hash.js';
|
|
3
|
+
import { writeFile, unlink, mkdir, rm } from 'fs/promises';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { tmpdir } from 'os';
|
|
6
|
+
|
|
7
|
+
describe('hash', () => {
|
|
8
|
+
let testDir: string;
|
|
9
|
+
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
testDir = join(tmpdir(), `vibefast-test-${Date.now()}`);
|
|
12
|
+
await mkdir(testDir, { recursive: true });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(async () => {
|
|
16
|
+
await rm(testDir, { recursive: true, force: true });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe('hashFile', () => {
|
|
20
|
+
it('should hash file content correctly', async () => {
|
|
21
|
+
const testFile = join(testDir, 'test.txt');
|
|
22
|
+
await writeFile(testFile, 'hello world');
|
|
23
|
+
|
|
24
|
+
const hash = await hashFile(testFile);
|
|
25
|
+
|
|
26
|
+
// SHA-256 of "hello world"
|
|
27
|
+
expect(hash).toBe('b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should return empty string for non-existent file', async () => {
|
|
31
|
+
const hash = await hashFile(join(testDir, 'does-not-exist.txt'));
|
|
32
|
+
expect(hash).toBe('');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should detect file modifications', async () => {
|
|
36
|
+
const testFile = join(testDir, 'test.txt');
|
|
37
|
+
await writeFile(testFile, 'original');
|
|
38
|
+
|
|
39
|
+
const hash1 = await hashFile(testFile);
|
|
40
|
+
|
|
41
|
+
await writeFile(testFile, 'modified');
|
|
42
|
+
|
|
43
|
+
const hash2 = await hashFile(testFile);
|
|
44
|
+
|
|
45
|
+
expect(hash1).not.toBe(hash2);
|
|
46
|
+
expect(hash1).not.toBe('');
|
|
47
|
+
expect(hash2).not.toBe('');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should produce same hash for same content', async () => {
|
|
51
|
+
const file1 = join(testDir, 'file1.txt');
|
|
52
|
+
const file2 = join(testDir, 'file2.txt');
|
|
53
|
+
|
|
54
|
+
await writeFile(file1, 'same content');
|
|
55
|
+
await writeFile(file2, 'same content');
|
|
56
|
+
|
|
57
|
+
const hash1 = await hashFile(file1);
|
|
58
|
+
const hash2 = await hashFile(file2);
|
|
59
|
+
|
|
60
|
+
expect(hash1).toBe(hash2);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('hashFiles', () => {
|
|
65
|
+
it('should hash multiple files', async () => {
|
|
66
|
+
const file1 = join(testDir, 'file1.txt');
|
|
67
|
+
const file2 = join(testDir, 'file2.txt');
|
|
68
|
+
|
|
69
|
+
await writeFile(file1, 'content 1');
|
|
70
|
+
await writeFile(file2, 'content 2');
|
|
71
|
+
|
|
72
|
+
const hashes = await hashFiles([file1, file2]);
|
|
73
|
+
|
|
74
|
+
expect(hashes.size).toBe(2);
|
|
75
|
+
expect(hashes.get(file1)).toBeTruthy();
|
|
76
|
+
expect(hashes.get(file2)).toBeTruthy();
|
|
77
|
+
expect(hashes.get(file1)).not.toBe(hashes.get(file2));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should skip binary files', async () => {
|
|
81
|
+
const textFile = join(testDir, 'file.txt');
|
|
82
|
+
const imageFile = join(testDir, 'image.png');
|
|
83
|
+
|
|
84
|
+
await writeFile(textFile, 'text content');
|
|
85
|
+
await writeFile(imageFile, 'fake image data');
|
|
86
|
+
|
|
87
|
+
const hashes = await hashFiles([textFile, imageFile]);
|
|
88
|
+
|
|
89
|
+
expect(hashes.size).toBe(2);
|
|
90
|
+
expect(hashes.get(textFile)).toBeTruthy();
|
|
91
|
+
expect(hashes.get(imageFile)).toBe(''); // Empty hash for binary
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('should handle empty array', async () => {
|
|
95
|
+
const hashes = await hashFiles([]);
|
|
96
|
+
expect(hashes.size).toBe(0);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('should handle non-existent files gracefully', async () => {
|
|
100
|
+
const existingFile = join(testDir, 'exists.txt');
|
|
101
|
+
const missingFile = join(testDir, 'missing.txt');
|
|
102
|
+
|
|
103
|
+
await writeFile(existingFile, 'content');
|
|
104
|
+
|
|
105
|
+
const hashes = await hashFiles([existingFile, missingFile]);
|
|
106
|
+
|
|
107
|
+
expect(hashes.size).toBe(1);
|
|
108
|
+
expect(hashes.get(existingFile)).toBeTruthy();
|
|
109
|
+
expect(hashes.has(missingFile)).toBe(false);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -116,4 +116,80 @@ describe('journal', () => {
|
|
|
116
116
|
expect(found).not.toBeNull();
|
|
117
117
|
expect(found?.feature).toBe('test');
|
|
118
118
|
});
|
|
119
|
+
|
|
120
|
+
it('should handle new format with file hashes', async () => {
|
|
121
|
+
const entry = {
|
|
122
|
+
feature: 'charts',
|
|
123
|
+
target: 'native' as const,
|
|
124
|
+
files: [
|
|
125
|
+
{ path: '/file1.ts', hash: 'abc123' },
|
|
126
|
+
{ path: '/file2.ts', hash: 'def456' },
|
|
127
|
+
],
|
|
128
|
+
insertedNav: true,
|
|
129
|
+
ts: Date.now(),
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
await addEntry(journalPath, entry);
|
|
133
|
+
const journal = await readJournal(journalPath);
|
|
134
|
+
|
|
135
|
+
expect(journal.entries).toHaveLength(1);
|
|
136
|
+
expect(journal.entries[0].files).toHaveLength(2);
|
|
137
|
+
expect(journal.entries[0].files[0]).toHaveProperty('path');
|
|
138
|
+
expect(journal.entries[0].files[0]).toHaveProperty('hash');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('should migrate old format to new format', async () => {
|
|
142
|
+
// Write old format manually
|
|
143
|
+
const oldJournal = {
|
|
144
|
+
entries: [
|
|
145
|
+
{
|
|
146
|
+
feature: 'old-feature',
|
|
147
|
+
target: 'native' as const,
|
|
148
|
+
files: ['/file1.ts', '/file2.ts'], // Old format: array of strings
|
|
149
|
+
insertedNav: true,
|
|
150
|
+
ts: Date.now(),
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
await writeJournal(journalPath, oldJournal as any);
|
|
156
|
+
|
|
157
|
+
// Read should auto-migrate
|
|
158
|
+
const journal = await readJournal(journalPath);
|
|
159
|
+
|
|
160
|
+
expect(journal.entries).toHaveLength(1);
|
|
161
|
+
expect(journal.entries[0].files).toHaveLength(2);
|
|
162
|
+
|
|
163
|
+
// Should be converted to new format
|
|
164
|
+
const firstFile = journal.entries[0].files[0];
|
|
165
|
+
expect(firstFile).toHaveProperty('path');
|
|
166
|
+
expect(firstFile).toHaveProperty('hash');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('should store manifest data', async () => {
|
|
170
|
+
const entry = {
|
|
171
|
+
feature: 'sentry',
|
|
172
|
+
target: 'native' as const,
|
|
173
|
+
files: [{ path: '/file.ts', hash: 'abc' }],
|
|
174
|
+
insertedNav: true,
|
|
175
|
+
ts: Date.now(),
|
|
176
|
+
manifest: {
|
|
177
|
+
version: '1.0.0',
|
|
178
|
+
manualSteps: [
|
|
179
|
+
{ title: 'Step 1', description: 'Do something' },
|
|
180
|
+
],
|
|
181
|
+
env: [
|
|
182
|
+
{ key: 'API_KEY', description: 'Your API key', example: 'xxx' },
|
|
183
|
+
],
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
await addEntry(journalPath, entry);
|
|
188
|
+
const found = await getEntry(journalPath, 'sentry', 'native');
|
|
189
|
+
|
|
190
|
+
expect(found?.manifest).toBeDefined();
|
|
191
|
+
expect(found?.manifest?.version).toBe('1.0.0');
|
|
192
|
+
expect(found?.manifest?.manualSteps).toHaveLength(1);
|
|
193
|
+
expect(found?.manifest?.env).toHaveLength(1);
|
|
194
|
+
});
|
|
119
195
|
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { promptYesNo } from '../prompt.js';
|
|
3
|
+
|
|
4
|
+
describe('prompt', () => {
|
|
5
|
+
let originalIsTTY: boolean | undefined;
|
|
6
|
+
let originalCI: string | undefined;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
originalIsTTY = process.stdin.isTTY;
|
|
10
|
+
originalCI = process.env.CI;
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
if (originalIsTTY !== undefined) {
|
|
15
|
+
(process.stdin as any).isTTY = originalIsTTY;
|
|
16
|
+
}
|
|
17
|
+
if (originalCI !== undefined) {
|
|
18
|
+
process.env.CI = originalCI;
|
|
19
|
+
} else {
|
|
20
|
+
delete process.env.CI;
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('promptYesNo', () => {
|
|
25
|
+
it('should return default value in non-TTY environment', () => {
|
|
26
|
+
(process.stdin as any).isTTY = false;
|
|
27
|
+
|
|
28
|
+
const result = promptYesNo('Continue?', true);
|
|
29
|
+
expect(result).toBe(true);
|
|
30
|
+
|
|
31
|
+
const result2 = promptYesNo('Continue?', false);
|
|
32
|
+
expect(result2).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should return default value in CI environment', () => {
|
|
36
|
+
process.env.CI = 'true';
|
|
37
|
+
|
|
38
|
+
const result = promptYesNo('Continue?', true);
|
|
39
|
+
expect(result).toBe(true);
|
|
40
|
+
|
|
41
|
+
const result2 = promptYesNo('Continue?', false);
|
|
42
|
+
expect(result2).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should detect GitHub Actions', () => {
|
|
46
|
+
process.env.GITHUB_ACTIONS = 'true';
|
|
47
|
+
|
|
48
|
+
const result = promptYesNo('Continue?', false);
|
|
49
|
+
expect(result).toBe(false);
|
|
50
|
+
|
|
51
|
+
delete process.env.GITHUB_ACTIONS;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should detect GitLab CI', () => {
|
|
55
|
+
process.env.GITLAB_CI = 'true';
|
|
56
|
+
|
|
57
|
+
const result = promptYesNo('Continue?', false);
|
|
58
|
+
expect(result).toBe(false);
|
|
59
|
+
|
|
60
|
+
delete process.env.GITLAB_CI;
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should detect CircleCI', () => {
|
|
64
|
+
process.env.CIRCLECI = 'true';
|
|
65
|
+
|
|
66
|
+
const result = promptYesNo('Continue?', false);
|
|
67
|
+
expect(result).toBe(false);
|
|
68
|
+
|
|
69
|
+
delete process.env.CIRCLECI;
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
});
|
package/src/core/fsx.ts
CHANGED
|
@@ -44,12 +44,24 @@ export async function deleteFile(path: string): Promise<void> {
|
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
export interface CopyResult {
|
|
48
|
+
files: string[];
|
|
49
|
+
conflicts: string[];
|
|
50
|
+
skipped: string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
47
53
|
export async function copyTree(
|
|
48
54
|
src: string,
|
|
49
55
|
dest: string,
|
|
50
|
-
options?: {
|
|
51
|
-
|
|
56
|
+
options?: {
|
|
57
|
+
dryRun?: boolean;
|
|
58
|
+
force?: boolean;
|
|
59
|
+
interactive?: boolean;
|
|
60
|
+
}
|
|
61
|
+
): Promise<CopyResult> {
|
|
52
62
|
const copied: string[] = [];
|
|
63
|
+
const conflicts: string[] = [];
|
|
64
|
+
const skipped: string[] = [];
|
|
53
65
|
|
|
54
66
|
async function copyRecursive(srcPath: string, destPath: string) {
|
|
55
67
|
const stats = await stat(srcPath);
|
|
@@ -64,9 +76,30 @@ export async function copyTree(
|
|
|
64
76
|
}
|
|
65
77
|
} else {
|
|
66
78
|
const destExists = await exists(destPath);
|
|
67
|
-
|
|
68
|
-
|
|
79
|
+
|
|
80
|
+
if (destExists) {
|
|
81
|
+
conflicts.push(destPath);
|
|
82
|
+
|
|
83
|
+
// If not force and not interactive, throw error
|
|
84
|
+
if (!options?.force && !options?.interactive) {
|
|
85
|
+
throw new Error(`File exists: ${destPath}. Use --force to overwrite.`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// If interactive and not dry-run, ask user
|
|
89
|
+
if (options?.interactive && !options?.dryRun) {
|
|
90
|
+
const { promptYesNo } = await import('./prompt.js');
|
|
91
|
+
const shouldOverwrite = promptYesNo(
|
|
92
|
+
`Overwrite ${destPath}? (y/N): `,
|
|
93
|
+
false
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
if (!shouldOverwrite) {
|
|
97
|
+
skipped.push(destPath);
|
|
98
|
+
return; // Skip this file
|
|
99
|
+
}
|
|
100
|
+
}
|
|
69
101
|
}
|
|
102
|
+
|
|
70
103
|
if (!options?.dryRun) {
|
|
71
104
|
await ensureDir(dirname(destPath));
|
|
72
105
|
await copyFile(srcPath, destPath);
|
|
@@ -76,5 +109,5 @@ export async function copyTree(
|
|
|
76
109
|
}
|
|
77
110
|
|
|
78
111
|
await copyRecursive(src, dest);
|
|
79
|
-
return copied;
|
|
112
|
+
return { files: copied, conflicts, skipped };
|
|
80
113
|
}
|
package/src/core/hash.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import { readFile, stat } from 'fs/promises';
|
|
3
|
+
import { log } from './log.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Hash a single file using SHA-256
|
|
7
|
+
* Returns empty string if file doesn't exist or can't be read
|
|
8
|
+
*/
|
|
9
|
+
export async function hashFile(filePath: string): Promise<string> {
|
|
10
|
+
try {
|
|
11
|
+
const content = await readFile(filePath);
|
|
12
|
+
return createHash('sha256').update(content).digest('hex');
|
|
13
|
+
} catch (error) {
|
|
14
|
+
// File doesn't exist or can't be read
|
|
15
|
+
return '';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Hash multiple files in batches
|
|
21
|
+
* Skips binary files and large files for performance
|
|
22
|
+
*/
|
|
23
|
+
export async function hashFiles(
|
|
24
|
+
filePaths: string[],
|
|
25
|
+
options?: { showProgress?: boolean }
|
|
26
|
+
): Promise<Map<string, string>> {
|
|
27
|
+
const hashes = new Map<string, string>();
|
|
28
|
+
|
|
29
|
+
// Filter out files we don't need to hash
|
|
30
|
+
const filesToHash: string[] = [];
|
|
31
|
+
|
|
32
|
+
for (const path of filePaths) {
|
|
33
|
+
// Skip binary files
|
|
34
|
+
if (/\.(png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|svg|mp4|mp3|pdf)$/i.test(path)) {
|
|
35
|
+
hashes.set(path, ''); // Store empty hash for binary files
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Skip large files (>1MB)
|
|
40
|
+
try {
|
|
41
|
+
const stats = await stat(path);
|
|
42
|
+
if (stats.size > 1024 * 1024) {
|
|
43
|
+
hashes.set(path, ''); // Store empty hash for large files
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
// File doesn't exist, skip
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
filesToHash.push(path);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Show progress if many files
|
|
55
|
+
if (options?.showProgress && filesToHash.length > 20) {
|
|
56
|
+
log.info(`Computing file hashes for ${filesToHash.length} files...`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Process in parallel batches
|
|
60
|
+
const batchSize = 10;
|
|
61
|
+
|
|
62
|
+
for (let i = 0; i < filesToHash.length; i += batchSize) {
|
|
63
|
+
const batch = filesToHash.slice(i, i + batchSize);
|
|
64
|
+
|
|
65
|
+
await Promise.all(
|
|
66
|
+
batch.map(async (path) => {
|
|
67
|
+
const hash = await hashFile(path);
|
|
68
|
+
hashes.set(path, hash);
|
|
69
|
+
})
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
// Show progress
|
|
73
|
+
if (options?.showProgress && filesToHash.length > 20) {
|
|
74
|
+
const progress = Math.min(i + batchSize, filesToHash.length);
|
|
75
|
+
process.stdout.write(`\r Progress: ${progress}/${filesToHash.length}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (options?.showProgress && filesToHash.length > 20) {
|
|
80
|
+
process.stdout.write('\n');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return hashes;
|
|
84
|
+
}
|
package/src/core/journal.ts
CHANGED
|
@@ -1,14 +1,25 @@
|
|
|
1
1
|
import { readFileContent, writeFileContent, exists, ensureDir } from './fsx.js';
|
|
2
2
|
import { dirname } from 'path';
|
|
3
|
+
import { hashFile } from './hash.js';
|
|
4
|
+
|
|
5
|
+
export interface FileEntry {
|
|
6
|
+
path: string;
|
|
7
|
+
hash: string;
|
|
8
|
+
}
|
|
3
9
|
|
|
4
10
|
export interface JournalEntry {
|
|
5
11
|
feature: string;
|
|
6
12
|
target: 'native' | 'web';
|
|
7
|
-
files: string[];
|
|
13
|
+
files: FileEntry[] | string[]; // Support both old and new format
|
|
8
14
|
insertedNav: boolean;
|
|
9
15
|
ts: number;
|
|
10
16
|
navHref?: string;
|
|
11
17
|
navLabel?: string;
|
|
18
|
+
manifest?: {
|
|
19
|
+
version?: string;
|
|
20
|
+
manualSteps?: any[];
|
|
21
|
+
env?: any[];
|
|
22
|
+
};
|
|
12
23
|
}
|
|
13
24
|
|
|
14
25
|
export interface Journal {
|
|
@@ -20,7 +31,34 @@ export async function readJournal(journalPath: string): Promise<Journal> {
|
|
|
20
31
|
return { entries: [] };
|
|
21
32
|
}
|
|
22
33
|
const content = await readFileContent(journalPath);
|
|
23
|
-
|
|
34
|
+
const journal = JSON.parse(content) as Journal;
|
|
35
|
+
|
|
36
|
+
// Migrate old format to new format
|
|
37
|
+
let needsMigration = false;
|
|
38
|
+
|
|
39
|
+
for (const entry of journal.entries) {
|
|
40
|
+
if (entry.files.length > 0 && typeof entry.files[0] === 'string') {
|
|
41
|
+
needsMigration = true;
|
|
42
|
+
// Old format: array of strings
|
|
43
|
+
const oldFiles = entry.files as string[];
|
|
44
|
+
|
|
45
|
+
// Convert to new format with hashes
|
|
46
|
+
const newFiles: FileEntry[] = [];
|
|
47
|
+
for (const filePath of oldFiles) {
|
|
48
|
+
const hash = await hashFile(filePath);
|
|
49
|
+
newFiles.push({ path: filePath, hash });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
entry.files = newFiles;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Save migrated journal
|
|
57
|
+
if (needsMigration) {
|
|
58
|
+
await writeJournal(journalPath, journal);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return journal;
|
|
24
62
|
}
|
|
25
63
|
|
|
26
64
|
export async function writeJournal(journalPath: string, journal: Journal): Promise<void> {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import readlineSync from 'readline-sync';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Prompt user for input
|
|
5
|
+
* Returns empty string in non-interactive environments (CI/CD)
|
|
6
|
+
*/
|
|
7
|
+
export function promptUser(question: string): string {
|
|
8
|
+
// Check if we're in a non-interactive environment
|
|
9
|
+
if (!process.stdin.isTTY) {
|
|
10
|
+
return '';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Check for CI environment variables
|
|
14
|
+
const isCI = process.env.CI === 'true' ||
|
|
15
|
+
process.env.GITHUB_ACTIONS === 'true' ||
|
|
16
|
+
process.env.GITLAB_CI === 'true' ||
|
|
17
|
+
process.env.CIRCLECI === 'true';
|
|
18
|
+
|
|
19
|
+
if (isCI) {
|
|
20
|
+
return '';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return readlineSync.question(question);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Prompt user for yes/no confirmation
|
|
28
|
+
* Returns defaultValue in non-interactive environments
|
|
29
|
+
*/
|
|
30
|
+
export function promptYesNo(question: string, defaultYes = false): boolean {
|
|
31
|
+
const answer = promptUser(question);
|
|
32
|
+
|
|
33
|
+
// Empty answer (non-interactive or just pressed enter)
|
|
34
|
+
if (answer === '') {
|
|
35
|
+
return defaultYes;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const normalized = answer.toLowerCase().trim();
|
|
39
|
+
return normalized === 'y' || normalized === 'yes';
|
|
40
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -9,6 +9,8 @@ import { doctorCommand } from './commands/doctor.js';
|
|
|
9
9
|
import { loginCommand } from './commands/login.js';
|
|
10
10
|
import { devicesCommand } from './commands/devices.js';
|
|
11
11
|
import { logoutCommand } from './commands/logout.js';
|
|
12
|
+
import { statusCommand } from './commands/status.js';
|
|
13
|
+
import { checklistCommand } from './commands/checklist.js';
|
|
12
14
|
|
|
13
15
|
const program = new Command();
|
|
14
16
|
|
|
@@ -24,5 +26,7 @@ program.addCommand(doctorCommand);
|
|
|
24
26
|
program.addCommand(listCommand);
|
|
25
27
|
program.addCommand(addCommand);
|
|
26
28
|
program.addCommand(removeCommand);
|
|
29
|
+
program.addCommand(statusCommand);
|
|
30
|
+
program.addCommand(checklistCommand);
|
|
27
31
|
|
|
28
32
|
program.parse();
|
package/text.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/$$ /$$ /$$$$$$ /$$$$$$$ /$$$$$$$$ /$$$$$$$$ /$$$$$$ /$$$$$$ /$$$$$$$$
|
|
2
|
+
| $$ | $$|_ $$_/| $$__ $$| $$_____/| $$_____//$$__ $$ /$$__ $$|__ $$__/
|
|
3
|
+
| $$ | $$ | $$ | $$ \ $$| $$ | $$ | $$ \ $$| $$ \__/ | $$
|
|
4
|
+
| $$ / $$/ | $$ | $$$$$$$ | $$$$$ | $$$$$ | $$$$$$$$| $$$$$$ | $$
|
|
5
|
+
\ $$ $$/ | $$ | $$__ $$| $$__/ | $$__/ | $$__ $$ \____ $$ | $$
|
|
6
|
+
\ $$$/ | $$ | $$ \ $$| $$ | $$ | $$ | $$ /$$ \ $$ | $$
|
|
7
|
+
\ $/ /$$$$$$| $$$$$$$/| $$$$$$$$| $$ | $$ | $$| $$$$$$/ | $$
|
|
8
|
+
\_/ |______/|_______/ |________/|__/ |__/ |__/ \______/ |__/
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
▖▖▄▖▄ ▄▖▄▖▄▖▄▖▄▖
|
|
17
|
+
▌▌▐ ▙▘▙▖▙▖▌▌▚ ▐
|
|
18
|
+
▚▘▟▖▙▘▙▖▌ ▛▌▄▌▐
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
██╗ ██╗██╗██████╗ ███████╗███████╗ █████╗ ███████╗████████╗
|
|
22
|
+
██║ ██║██║██╔══██╗██╔════╝██╔════╝██╔══██╗██╔════╝╚══██╔══╝
|
|
23
|
+
██║ ██║██║██████╔╝█████╗ █████╗ ███████║███████╗ ██║
|
|
24
|
+
╚██╗ ██╔╝██║██╔══██╗██╔══╝ ██╔══╝ ██╔══██║╚════██║ ██║
|
|
25
|
+
╚████╔╝ ██║██████╔╝███████╗██║ ██║ ██║███████║ ██║
|
|
26
|
+
╚═══╝ ╚═╝╚═════╝ ╚══════╝╚═╝ ╚═╝ ╚═╝╚══════╝ ╚═╝
|
|
27
|
+
|