prjct-cli 1.6.13 → 1.7.1
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/CHANGELOG.md +62 -4
- package/core/__tests__/storage/safe-reader.test.ts +262 -0
- package/core/__tests__/utils/date-helper.test.ts +44 -0
- package/core/schemas/shipped.ts +3 -3
- package/core/storage/ideas-storage.ts +9 -8
- package/core/storage/metrics-storage.ts +2 -1
- package/core/storage/queue-storage.ts +2 -1
- package/core/storage/safe-reader.ts +105 -0
- package/core/storage/shipped-storage.ts +5 -7
- package/core/storage/state-storage.ts +5 -4
- package/core/storage/storage-manager.ts +19 -3
- package/core/utils/date-helper.ts +10 -0
- package/core/utils/file-helper.ts +10 -2
- package/dist/bin/prjct.mjs +561 -480
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,12 +1,70 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## [1.
|
|
3
|
+
## [1.7.1] - 2026-02-07
|
|
4
4
|
|
|
5
|
-
###
|
|
5
|
+
### Bug Fixes
|
|
6
|
+
|
|
7
|
+
- add Zod validation on all storage reads (PRJ-279) (#140)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
## [1.7.1] - 2026-02-07
|
|
11
|
+
|
|
12
|
+
### Bug Fix
|
|
13
|
+
- **Add Zod validation on all storage reads (PRJ-279)**: Created `safeRead<T>()` utility that wraps `JSON.parse` + `schema.safeParse()`. All 5 `StorageManager` subclasses (state, queue, ideas, shipped, metrics) now validate reads against their Zod schemas. Corrupted files produce a logged warning + `.backup` file instead of silently crashing downstream.
|
|
14
|
+
|
|
15
|
+
### Implementation Details
|
|
16
|
+
Created `core/storage/safe-reader.ts` with a `ValidationSchema` interface decoupled from Zod generics to avoid strict type parameter matching. The `StorageManager` base class accepts an optional schema via constructor — subclasses pass their Zod schema with a single import + arg change. `safeRead` returns the raw parsed JSON (not Zod-transformed `result.data`) to preserve extra fields for forward compatibility. Also fixed `ShippedJsonSchema` which used `items` instead of `shipped` (pre-existing schema bug), and made `changes` optional to match actual data.
|
|
6
17
|
|
|
7
|
-
|
|
8
|
-
-
|
|
18
|
+
### Learnings
|
|
19
|
+
- Zod's default `strip` mode silently drops unknown keys from `result.data` — must return raw JSON to preserve extra state.json fields (projectId, stack, domains, etc.)
|
|
20
|
+
- `ShippedJsonSchema` had `items` instead of `shipped` as the array key — pre-existing schema/data mismatch
|
|
21
|
+
- `ValidationSchema` interface avoids Zod generic constraints while still providing type-safe validation
|
|
22
|
+
|
|
23
|
+
### Test Plan
|
|
24
|
+
|
|
25
|
+
#### For QA
|
|
26
|
+
1. Create a valid `state.json` — verify it reads correctly with no warnings
|
|
27
|
+
2. Corrupt a storage file with invalid JSON — verify `.backup` is created and defaults returned
|
|
28
|
+
3. Write valid JSON with wrong schema — verify `.backup` and defaults
|
|
29
|
+
4. Add extra fields not in schema — verify they are preserved after read
|
|
30
|
+
5. Run `bun test` — verify all 438 tests pass (16 new for `safeRead`)
|
|
31
|
+
|
|
32
|
+
#### For Users
|
|
33
|
+
**What changed:** Storage reads are now validated against Zod schemas. Corrupted files no longer cause silent crashes.
|
|
34
|
+
**How to use:** No action needed — automatic.
|
|
35
|
+
**Breaking changes:** None.
|
|
9
36
|
|
|
37
|
+
## [1.7.0] - 2026-02-07
|
|
38
|
+
|
|
39
|
+
### Features
|
|
40
|
+
|
|
41
|
+
- use relative timestamps to reduce token waste (PRJ-274) (#139)
|
|
42
|
+
- use relative timestamps to reduce token waste (PRJ-274)
|
|
43
|
+
|
|
44
|
+
## [1.6.16] - 2026-02-07
|
|
45
|
+
|
|
46
|
+
### Improvement
|
|
47
|
+
- **Use relative timestamps to reduce token waste (PRJ-274)**: Added `toRelative()` function using `date-fns` `formatDistanceToNowStrict`. Replaced raw ISO-8601 timestamps in Markdown context files (`now.md`, `ideas.md`, `shipped.md`) with human-readable relative time ("5 minutes ago", "3 days ago"). JSON storage retains full ISO timestamps — no data loss.
|
|
48
|
+
|
|
49
|
+
### Implementation Details
|
|
50
|
+
Added `date-fns` as a dependency and created a thin `toRelative(date)` wrapper around `formatDistanceToNowStrict` in `core/utils/date-helper.ts`. Updated `toMarkdown()` in `state-storage.ts` (Started/Paused fields), `ideas-storage.ts` (all 3 sections: pending, converted, archived), and `shipped-storage.ts` (ship date per entry). 6 new unit tests added covering minutes, hours, days, months, Date objects, and ISO string inputs.
|
|
51
|
+
|
|
52
|
+
### Learnings
|
|
53
|
+
- `date-fns` `formatDistanceToNowStrict` gives exact units ("5 minutes ago" not "about 5 minutes ago") — better for token efficiency
|
|
54
|
+
- Tests need `setSystemTime()` from `bun:test` since `formatDistanceToNowStrict` uses system clock internally
|
|
55
|
+
|
|
56
|
+
### Test Plan
|
|
57
|
+
|
|
58
|
+
#### For QA
|
|
59
|
+
1. Run `bun test core/__tests__/utils/date-helper.test.ts` — verify all 55 tests pass (6 new for `toRelative`)
|
|
60
|
+
2. Run `bun run build` — verify build succeeds
|
|
61
|
+
3. Run `prjct sync` — verify `context/now.md` shows relative timestamps instead of raw ISO
|
|
62
|
+
4. Check `ideas.md` and `shipped.md` for relative date format
|
|
63
|
+
|
|
64
|
+
#### For Users
|
|
65
|
+
**What changed:** Timestamps in context files now show "5 minutes ago", "3 days ago" instead of raw ISO-8601 strings.
|
|
66
|
+
**How to use:** No action needed — automatic.
|
|
67
|
+
**Breaking changes:** None.
|
|
10
68
|
|
|
11
69
|
## [1.6.15] - 2026-02-07
|
|
12
70
|
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safe Reader Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for Zod-validated storage reads:
|
|
5
|
+
* - Valid data passes through
|
|
6
|
+
* - Corrupted JSON creates .backup + returns null
|
|
7
|
+
* - Valid JSON with wrong schema creates .backup + returns null
|
|
8
|
+
* - Missing files return null (no backup)
|
|
9
|
+
* - Extra fields are preserved (forward compatibility)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
|
13
|
+
import fs from 'node:fs/promises'
|
|
14
|
+
import os from 'node:os'
|
|
15
|
+
import path from 'node:path'
|
|
16
|
+
import { z } from 'zod'
|
|
17
|
+
import { safeRead } from '../../storage/safe-reader'
|
|
18
|
+
|
|
19
|
+
// =============================================================================
|
|
20
|
+
// Test Schema
|
|
21
|
+
// =============================================================================
|
|
22
|
+
|
|
23
|
+
const TestSchema = z.object({
|
|
24
|
+
name: z.string(),
|
|
25
|
+
count: z.number(),
|
|
26
|
+
items: z.array(z.string()),
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
type TestData = z.infer<typeof TestSchema>
|
|
30
|
+
|
|
31
|
+
// =============================================================================
|
|
32
|
+
// Setup
|
|
33
|
+
// =============================================================================
|
|
34
|
+
|
|
35
|
+
let tmpDir: string
|
|
36
|
+
|
|
37
|
+
beforeEach(async () => {
|
|
38
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'prjct-safe-reader-test-'))
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
afterEach(async () => {
|
|
42
|
+
await fs.rm(tmpDir, { recursive: true, force: true })
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
// =============================================================================
|
|
46
|
+
// Tests
|
|
47
|
+
// =============================================================================
|
|
48
|
+
|
|
49
|
+
describe('safeRead', () => {
|
|
50
|
+
describe('valid data', () => {
|
|
51
|
+
it('should return validated data for valid JSON matching schema', async () => {
|
|
52
|
+
const filePath = path.join(tmpDir, 'valid.json')
|
|
53
|
+
const data: TestData = { name: 'test', count: 42, items: ['a', 'b'] }
|
|
54
|
+
await fs.writeFile(filePath, JSON.stringify(data, null, 2))
|
|
55
|
+
|
|
56
|
+
const result = await safeRead<TestData>(filePath, TestSchema)
|
|
57
|
+
|
|
58
|
+
expect(result).toEqual(data)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('should preserve extra fields not in schema', async () => {
|
|
62
|
+
const filePath = path.join(tmpDir, 'extra-fields.json')
|
|
63
|
+
const data = {
|
|
64
|
+
name: 'test',
|
|
65
|
+
count: 1,
|
|
66
|
+
items: [],
|
|
67
|
+
extraField: 'preserved',
|
|
68
|
+
nested: { deep: true },
|
|
69
|
+
}
|
|
70
|
+
await fs.writeFile(filePath, JSON.stringify(data, null, 2))
|
|
71
|
+
|
|
72
|
+
const result = await safeRead<typeof data>(filePath, TestSchema)
|
|
73
|
+
|
|
74
|
+
expect(result).not.toBeNull()
|
|
75
|
+
expect(result!.name).toBe('test')
|
|
76
|
+
expect(result!.extraField).toBe('preserved')
|
|
77
|
+
expect(result!.nested).toEqual({ deep: true })
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('should not create .backup for valid data', async () => {
|
|
81
|
+
const filePath = path.join(tmpDir, 'no-backup.json')
|
|
82
|
+
const data: TestData = { name: 'ok', count: 0, items: [] }
|
|
83
|
+
await fs.writeFile(filePath, JSON.stringify(data))
|
|
84
|
+
|
|
85
|
+
await safeRead<TestData>(filePath, TestSchema)
|
|
86
|
+
|
|
87
|
+
const backupExists = await fs
|
|
88
|
+
.access(`${filePath}.backup`)
|
|
89
|
+
.then(() => true)
|
|
90
|
+
.catch(() => false)
|
|
91
|
+
expect(backupExists).toBe(false)
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
describe('missing files', () => {
|
|
96
|
+
it('should return null for non-existent file', async () => {
|
|
97
|
+
const result = await safeRead<TestData>(path.join(tmpDir, 'missing.json'), TestSchema)
|
|
98
|
+
|
|
99
|
+
expect(result).toBeNull()
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('should not create .backup for missing file', async () => {
|
|
103
|
+
const filePath = path.join(tmpDir, 'missing.json')
|
|
104
|
+
await safeRead<TestData>(filePath, TestSchema)
|
|
105
|
+
|
|
106
|
+
const backupExists = await fs
|
|
107
|
+
.access(`${filePath}.backup`)
|
|
108
|
+
.then(() => true)
|
|
109
|
+
.catch(() => false)
|
|
110
|
+
expect(backupExists).toBe(false)
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
describe('corrupted JSON', () => {
|
|
115
|
+
it('should return null for malformed JSON', async () => {
|
|
116
|
+
const filePath = path.join(tmpDir, 'malformed.json')
|
|
117
|
+
await fs.writeFile(filePath, 'not valid json {{{')
|
|
118
|
+
|
|
119
|
+
const result = await safeRead<TestData>(filePath, TestSchema)
|
|
120
|
+
|
|
121
|
+
expect(result).toBeNull()
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('should create .backup for malformed JSON', async () => {
|
|
125
|
+
const filePath = path.join(tmpDir, 'malformed.json')
|
|
126
|
+
const badContent = 'not valid json {{{'
|
|
127
|
+
await fs.writeFile(filePath, badContent)
|
|
128
|
+
|
|
129
|
+
await safeRead<TestData>(filePath, TestSchema)
|
|
130
|
+
|
|
131
|
+
const backup = await fs.readFile(`${filePath}.backup`, 'utf-8')
|
|
132
|
+
expect(backup).toBe(badContent)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('should return null for empty file', async () => {
|
|
136
|
+
const filePath = path.join(tmpDir, 'empty.json')
|
|
137
|
+
await fs.writeFile(filePath, '')
|
|
138
|
+
|
|
139
|
+
const result = await safeRead<TestData>(filePath, TestSchema)
|
|
140
|
+
|
|
141
|
+
expect(result).toBeNull()
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
describe('valid JSON with wrong schema', () => {
|
|
146
|
+
it('should return null when required field is missing', async () => {
|
|
147
|
+
const filePath = path.join(tmpDir, 'missing-field.json')
|
|
148
|
+
await fs.writeFile(filePath, JSON.stringify({ name: 'test' })) // missing count and items
|
|
149
|
+
|
|
150
|
+
const result = await safeRead<TestData>(filePath, TestSchema)
|
|
151
|
+
|
|
152
|
+
expect(result).toBeNull()
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('should create .backup when schema validation fails', async () => {
|
|
156
|
+
const filePath = path.join(tmpDir, 'wrong-schema.json')
|
|
157
|
+
const data = { name: 123, count: 'not a number', items: 'not an array' }
|
|
158
|
+
await fs.writeFile(filePath, JSON.stringify(data))
|
|
159
|
+
|
|
160
|
+
await safeRead<TestData>(filePath, TestSchema)
|
|
161
|
+
|
|
162
|
+
const backupExists = await fs
|
|
163
|
+
.access(`${filePath}.backup`)
|
|
164
|
+
.then(() => true)
|
|
165
|
+
.catch(() => false)
|
|
166
|
+
expect(backupExists).toBe(true)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('should return null when field has wrong type', async () => {
|
|
170
|
+
const filePath = path.join(tmpDir, 'wrong-type.json')
|
|
171
|
+
await fs.writeFile(filePath, JSON.stringify({ name: 42, count: 1, items: [] }))
|
|
172
|
+
|
|
173
|
+
const result = await safeRead<TestData>(filePath, TestSchema)
|
|
174
|
+
|
|
175
|
+
expect(result).toBeNull()
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('should return null when array contains wrong types', async () => {
|
|
179
|
+
const filePath = path.join(tmpDir, 'wrong-array.json')
|
|
180
|
+
await fs.writeFile(filePath, JSON.stringify({ name: 'test', count: 1, items: [1, 2, 3] }))
|
|
181
|
+
|
|
182
|
+
const result = await safeRead<TestData>(filePath, TestSchema)
|
|
183
|
+
|
|
184
|
+
expect(result).toBeNull()
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
describe('optional fields and defaults', () => {
|
|
189
|
+
it('should handle schema with optional fields', async () => {
|
|
190
|
+
const OptionalSchema = z.object({
|
|
191
|
+
name: z.string(),
|
|
192
|
+
description: z.string().optional(),
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
const filePath = path.join(tmpDir, 'optional.json')
|
|
196
|
+
await fs.writeFile(filePath, JSON.stringify({ name: 'test' }))
|
|
197
|
+
|
|
198
|
+
const result = await safeRead<z.infer<typeof OptionalSchema>>(filePath, OptionalSchema)
|
|
199
|
+
|
|
200
|
+
expect(result).not.toBeNull()
|
|
201
|
+
expect(result!.name).toBe('test')
|
|
202
|
+
expect(result!.description).toBeUndefined()
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('should handle schema with nullable fields', async () => {
|
|
206
|
+
const NullableSchema = z.object({
|
|
207
|
+
currentTask: z.object({ id: z.string() }).nullable(),
|
|
208
|
+
lastUpdated: z.string(),
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
const filePath = path.join(tmpDir, 'nullable.json')
|
|
212
|
+
await fs.writeFile(filePath, JSON.stringify({ currentTask: null, lastUpdated: '2026-01-01' }))
|
|
213
|
+
|
|
214
|
+
const result = await safeRead<z.infer<typeof NullableSchema>>(filePath, NullableSchema)
|
|
215
|
+
|
|
216
|
+
expect(result).not.toBeNull()
|
|
217
|
+
expect(result!.currentTask).toBeNull()
|
|
218
|
+
})
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
describe('integration with StorageManager pattern', () => {
|
|
222
|
+
it('should work with real StateJsonSchema', async () => {
|
|
223
|
+
// Import the actual schema used in production
|
|
224
|
+
const { StateJsonSchema } = await import('../../schemas/state')
|
|
225
|
+
|
|
226
|
+
const filePath = path.join(tmpDir, 'state.json')
|
|
227
|
+
const stateData = {
|
|
228
|
+
currentTask: null,
|
|
229
|
+
lastUpdated: '2026-02-07T00:00:00.000Z',
|
|
230
|
+
// Extra fields that exist in real state.json but not in schema
|
|
231
|
+
projectId: 'test-123',
|
|
232
|
+
stack: { language: 'TypeScript', framework: 'Hono' },
|
|
233
|
+
}
|
|
234
|
+
await fs.writeFile(filePath, JSON.stringify(stateData, null, 2))
|
|
235
|
+
|
|
236
|
+
const result = await safeRead<typeof stateData>(filePath, StateJsonSchema)
|
|
237
|
+
|
|
238
|
+
expect(result).not.toBeNull()
|
|
239
|
+
expect(result!.currentTask).toBeNull()
|
|
240
|
+
expect(result!.projectId).toBe('test-123') // Extra field preserved
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('should reject corrupted state data', async () => {
|
|
244
|
+
const { StateJsonSchema } = await import('../../schemas/state')
|
|
245
|
+
|
|
246
|
+
const filePath = path.join(tmpDir, 'bad-state.json')
|
|
247
|
+
// currentTask should be an object or null, not a number
|
|
248
|
+
const badData = { currentTask: 42, lastUpdated: '2026-02-07' }
|
|
249
|
+
await fs.writeFile(filePath, JSON.stringify(badData))
|
|
250
|
+
|
|
251
|
+
const result = await safeRead(filePath, StateJsonSchema)
|
|
252
|
+
|
|
253
|
+
expect(result).toBeNull()
|
|
254
|
+
// Backup should exist
|
|
255
|
+
const backupExists = await fs
|
|
256
|
+
.access(`${filePath}.backup`)
|
|
257
|
+
.then(() => true)
|
|
258
|
+
.catch(() => false)
|
|
259
|
+
expect(backupExists).toBe(true)
|
|
260
|
+
})
|
|
261
|
+
})
|
|
262
|
+
})
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
isToday,
|
|
22
22
|
isWithinLastDays,
|
|
23
23
|
parseDate,
|
|
24
|
+
toRelative,
|
|
24
25
|
} from '../../utils/date-helper'
|
|
25
26
|
|
|
26
27
|
describe('DateHelper', () => {
|
|
@@ -402,4 +403,47 @@ describe('DateHelper', () => {
|
|
|
402
403
|
expect(original.getMinutes()).toBe(30)
|
|
403
404
|
})
|
|
404
405
|
})
|
|
406
|
+
|
|
407
|
+
describe('toRelative', () => {
|
|
408
|
+
it('should show minutes for dates within 1 hour', () => {
|
|
409
|
+
setSystemTime(new Date('2025-10-15T12:00:00.000Z'))
|
|
410
|
+
const fiveMinAgo = '2025-10-15T11:55:00.000Z'
|
|
411
|
+
expect(toRelative(fiveMinAgo)).toBe('5 minutes ago')
|
|
412
|
+
setSystemTime()
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
it('should show hours for dates within 24 hours', () => {
|
|
416
|
+
setSystemTime(new Date('2025-10-15T12:00:00.000Z'))
|
|
417
|
+
const threeHoursAgo = '2025-10-15T09:00:00.000Z'
|
|
418
|
+
expect(toRelative(threeHoursAgo)).toBe('3 hours ago')
|
|
419
|
+
setSystemTime()
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
it('should show days for dates within 7 days', () => {
|
|
423
|
+
setSystemTime(new Date('2025-10-15T12:00:00.000Z'))
|
|
424
|
+
const twoDaysAgo = '2025-10-13T12:00:00.000Z'
|
|
425
|
+
expect(toRelative(twoDaysAgo)).toBe('2 days ago')
|
|
426
|
+
setSystemTime()
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
it('should show months for dates older than 30 days', () => {
|
|
430
|
+
setSystemTime(new Date('2025-10-15T12:00:00.000Z'))
|
|
431
|
+
const twoMonthsAgo = '2025-08-15T12:00:00.000Z'
|
|
432
|
+
expect(toRelative(twoMonthsAgo)).toBe('2 months ago')
|
|
433
|
+
setSystemTime()
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
it('should accept Date objects', () => {
|
|
437
|
+
setSystemTime(new Date('2025-10-15T12:00:00.000Z'))
|
|
438
|
+
const date = new Date('2025-10-15T11:00:00.000Z')
|
|
439
|
+
expect(toRelative(date)).toBe('1 hour ago')
|
|
440
|
+
setSystemTime()
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
it('should accept ISO string timestamps', () => {
|
|
444
|
+
setSystemTime(new Date('2025-10-15T12:00:00.000Z'))
|
|
445
|
+
expect(toRelative('2025-10-14T12:00:00.000Z')).toBe('1 day ago')
|
|
446
|
+
setSystemTime()
|
|
447
|
+
})
|
|
448
|
+
})
|
|
405
449
|
})
|
package/core/schemas/shipped.ts
CHANGED
|
@@ -56,7 +56,7 @@ export const ShippedItemSchema = z.object({
|
|
|
56
56
|
type: ShipTypeSchema,
|
|
57
57
|
agent: z.string().optional(), // "fe+be", "be", "fe"
|
|
58
58
|
description: z.string().optional(),
|
|
59
|
-
changes: z.array(ShipChangeSchema),
|
|
59
|
+
changes: z.array(ShipChangeSchema).optional(),
|
|
60
60
|
codeSnippets: z.array(z.string()).optional(),
|
|
61
61
|
commit: CommitInfoSchema.optional(),
|
|
62
62
|
codeMetrics: CodeMetricsSchema.optional(),
|
|
@@ -69,7 +69,7 @@ export const ShippedItemSchema = z.object({
|
|
|
69
69
|
})
|
|
70
70
|
|
|
71
71
|
export const ShippedJsonSchema = z.object({
|
|
72
|
-
|
|
72
|
+
shipped: z.array(ShippedItemSchema),
|
|
73
73
|
lastUpdated: z.string(),
|
|
74
74
|
})
|
|
75
75
|
|
|
@@ -104,6 +104,6 @@ export const safeParseShipped = (data: unknown) => ShippedJsonSchema.safeParse(d
|
|
|
104
104
|
// =============================================================================
|
|
105
105
|
|
|
106
106
|
export const DEFAULT_SHIPPED: ShippedJson = {
|
|
107
|
-
|
|
107
|
+
shipped: [],
|
|
108
108
|
lastUpdated: '',
|
|
109
109
|
}
|
|
@@ -6,13 +6,14 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { generateUUID } from '../schemas'
|
|
9
|
+
import { IdeasJsonSchema } from '../schemas/ideas'
|
|
9
10
|
import type { Idea, IdeaPriority, IdeaStatus, IdeasJson } from '../types'
|
|
10
|
-
import { getTimestamp } from '../utils/date-helper'
|
|
11
|
+
import { getTimestamp, toRelative } from '../utils/date-helper'
|
|
11
12
|
import { StorageManager } from './storage-manager'
|
|
12
13
|
|
|
13
14
|
class IdeasStorage extends StorageManager<IdeasJson> {
|
|
14
15
|
constructor() {
|
|
15
|
-
super('ideas.json')
|
|
16
|
+
super('ideas.json', IdeasJsonSchema)
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
protected getDefault(): IdeasJson {
|
|
@@ -45,10 +46,10 @@ class IdeasStorage extends StorageManager<IdeasJson> {
|
|
|
45
46
|
lines.push('## Brain Dump')
|
|
46
47
|
if (pending.length > 0) {
|
|
47
48
|
pending.forEach((idea) => {
|
|
48
|
-
const
|
|
49
|
+
const rel = toRelative(idea.addedAt)
|
|
49
50
|
const tags = idea.tags.length > 0 ? ` ${idea.tags.map((t) => `#${t}`).join(' ')}` : ''
|
|
50
51
|
const priority = idea.priority !== 'medium' ? ` [${idea.priority.toUpperCase()}]` : ''
|
|
51
|
-
lines.push(`- ${idea.text}${priority} _(${
|
|
52
|
+
lines.push(`- ${idea.text}${priority} _(${rel})_${tags}`)
|
|
52
53
|
})
|
|
53
54
|
} else {
|
|
54
55
|
lines.push('_No pending ideas_')
|
|
@@ -59,9 +60,9 @@ class IdeasStorage extends StorageManager<IdeasJson> {
|
|
|
59
60
|
if (converted.length > 0) {
|
|
60
61
|
lines.push('## Converted')
|
|
61
62
|
converted.forEach((idea) => {
|
|
62
|
-
const
|
|
63
|
+
const rel = toRelative(idea.addedAt)
|
|
63
64
|
const feat = idea.convertedTo ? ` \u2192 ${idea.convertedTo}` : ''
|
|
64
|
-
lines.push(`- \u2713 ${idea.text}${feat} _(${
|
|
65
|
+
lines.push(`- \u2713 ${idea.text}${feat} _(${rel})_`)
|
|
65
66
|
})
|
|
66
67
|
lines.push('')
|
|
67
68
|
}
|
|
@@ -70,8 +71,8 @@ class IdeasStorage extends StorageManager<IdeasJson> {
|
|
|
70
71
|
if (archived.length > 0) {
|
|
71
72
|
lines.push('## Archived')
|
|
72
73
|
archived.forEach((idea) => {
|
|
73
|
-
const
|
|
74
|
-
lines.push(`- ${idea.text} _(${
|
|
74
|
+
const rel = toRelative(idea.addedAt)
|
|
75
|
+
lines.push(`- ${idea.text} _(${rel})_`)
|
|
75
76
|
})
|
|
76
77
|
lines.push('')
|
|
77
78
|
}
|
|
@@ -18,13 +18,14 @@ import {
|
|
|
18
18
|
estimateCostSaved,
|
|
19
19
|
formatCost,
|
|
20
20
|
type MetricsJson,
|
|
21
|
+
MetricsJsonSchema,
|
|
21
22
|
} from '../schemas/metrics'
|
|
22
23
|
import { getTimestamp } from '../utils/date-helper'
|
|
23
24
|
import { StorageManager } from './storage-manager'
|
|
24
25
|
|
|
25
26
|
class MetricsStorage extends StorageManager<MetricsJson> {
|
|
26
27
|
constructor() {
|
|
27
|
-
super('metrics.json')
|
|
28
|
+
super('metrics.json', MetricsJsonSchema)
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
protected getDefault(): MetricsJson {
|
|
@@ -7,12 +7,13 @@
|
|
|
7
7
|
|
|
8
8
|
import { generateUUID } from '../schemas'
|
|
9
9
|
import type { Priority, QueueJson, QueueTask, TaskSection } from '../schemas/state'
|
|
10
|
+
import { QueueJsonSchema } from '../schemas/state'
|
|
10
11
|
import { getTimestamp } from '../utils/date-helper'
|
|
11
12
|
import { StorageManager } from './storage-manager'
|
|
12
13
|
|
|
13
14
|
class QueueStorage extends StorageManager<QueueJson> {
|
|
14
15
|
constructor() {
|
|
15
|
-
super('queue.json')
|
|
16
|
+
super('queue.json', QueueJsonSchema)
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
protected getDefault(): QueueJson {
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safe Reader
|
|
3
|
+
*
|
|
4
|
+
* Wraps JSON.parse + Zod schema.safeParse() for validated storage reads.
|
|
5
|
+
* On corruption: logs warning, creates .backup, returns null.
|
|
6
|
+
*
|
|
7
|
+
* Uses schema for structural validation only — returns the raw parsed
|
|
8
|
+
* data (not Zod-transformed) to preserve extra fields that may exist
|
|
9
|
+
* in storage files but aren't in the schema yet (forward compatibility).
|
|
10
|
+
*
|
|
11
|
+
* @version 1.0.0
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import fs from 'node:fs/promises'
|
|
15
|
+
import type { ZodError } from 'zod'
|
|
16
|
+
import { isNotFoundError } from '../types/fs'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Minimal interface for Zod-like validation.
|
|
20
|
+
* Decoupled from Zod generics to avoid strict type parameter matching.
|
|
21
|
+
*/
|
|
22
|
+
export interface ValidationSchema {
|
|
23
|
+
safeParse(data: unknown): { success: boolean; error?: ZodError }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Read and validate a JSON file against a Zod schema.
|
|
28
|
+
*
|
|
29
|
+
* Flow:
|
|
30
|
+
* 1. Read file → if missing, return null
|
|
31
|
+
* 2. JSON.parse → if malformed JSON, backup + return null
|
|
32
|
+
* 3. schema.safeParse → if valid, return raw parsed data as T
|
|
33
|
+
* 4. If invalid but parseable JSON, backup + return null
|
|
34
|
+
*
|
|
35
|
+
* Returns raw parsed JSON (not Zod-transformed) to preserve extra fields.
|
|
36
|
+
*
|
|
37
|
+
* @returns Validated data or null if file is missing/corrupted
|
|
38
|
+
*/
|
|
39
|
+
export async function safeRead<T>(filePath: string, schema: ValidationSchema): Promise<T | null> {
|
|
40
|
+
let content: string
|
|
41
|
+
|
|
42
|
+
// Step 1: Read file
|
|
43
|
+
try {
|
|
44
|
+
content = await fs.readFile(filePath, 'utf-8')
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (isNotFoundError(error)) {
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
throw error
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Step 2: JSON.parse
|
|
53
|
+
let raw: unknown
|
|
54
|
+
try {
|
|
55
|
+
raw = JSON.parse(content)
|
|
56
|
+
} catch {
|
|
57
|
+
// Malformed JSON — backup and return null
|
|
58
|
+
await createBackup(filePath, content)
|
|
59
|
+
logCorruption(filePath, 'Malformed JSON')
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Step 3: Validate against schema
|
|
64
|
+
const result = schema.safeParse(raw)
|
|
65
|
+
if (result.success) {
|
|
66
|
+
// Return raw data to preserve extra fields not in schema
|
|
67
|
+
return raw as T
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Step 4: Validation failed — backup and return null
|
|
71
|
+
await createBackup(filePath, content)
|
|
72
|
+
logCorruption(filePath, formatZodError(result.error!))
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Create a .backup of a corrupted file
|
|
78
|
+
*/
|
|
79
|
+
async function createBackup(filePath: string, content: string): Promise<void> {
|
|
80
|
+
const backupPath = `${filePath}.backup`
|
|
81
|
+
try {
|
|
82
|
+
await fs.writeFile(backupPath, content, 'utf-8')
|
|
83
|
+
} catch {
|
|
84
|
+
// Best-effort backup — don't throw if it fails
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Log corruption warning to stderr
|
|
90
|
+
*/
|
|
91
|
+
function logCorruption(filePath: string, reason: string): void {
|
|
92
|
+
console.error(`[prjct] Warning: Corrupted storage file: ${filePath}`)
|
|
93
|
+
console.error(`[prjct] Reason: ${reason}`)
|
|
94
|
+
console.error(`[prjct] A .backup file has been created. Returning defaults.`)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Format Zod error into a readable string
|
|
99
|
+
*/
|
|
100
|
+
function formatZodError(error: ZodError): string {
|
|
101
|
+
return error.issues
|
|
102
|
+
.slice(0, 3)
|
|
103
|
+
.map((issue) => `${issue.path.join('.')}: ${issue.message}`)
|
|
104
|
+
.join('; ')
|
|
105
|
+
}
|
|
@@ -6,13 +6,14 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { generateUUID } from '../schemas'
|
|
9
|
+
import { ShippedJsonSchema } from '../schemas/shipped'
|
|
9
10
|
import type { ShippedFeature, ShippedJson } from '../types'
|
|
10
|
-
import { getTimestamp } from '../utils/date-helper'
|
|
11
|
+
import { getTimestamp, toRelative } from '../utils/date-helper'
|
|
11
12
|
import { StorageManager } from './storage-manager'
|
|
12
13
|
|
|
13
14
|
class ShippedStorage extends StorageManager<ShippedJson> {
|
|
14
15
|
constructor() {
|
|
15
|
-
super('shipped.json')
|
|
16
|
+
super('shipped.json', ShippedJsonSchema)
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
protected getDefault(): ShippedJson {
|
|
@@ -72,13 +73,10 @@ class ShippedStorage extends StorageManager<ShippedJson> {
|
|
|
72
73
|
.sort((a, b) => new Date(b.shippedAt).getTime() - new Date(a.shippedAt).getTime())
|
|
73
74
|
|
|
74
75
|
ships.forEach((ship) => {
|
|
75
|
-
const
|
|
76
|
-
month: 'short',
|
|
77
|
-
day: 'numeric',
|
|
78
|
-
})
|
|
76
|
+
const rel = toRelative(ship.shippedAt)
|
|
79
77
|
const version = ship.version ? ` v${ship.version}` : ''
|
|
80
78
|
const duration = ship.duration ? ` (${ship.duration})` : ''
|
|
81
|
-
lines.push(`- **${ship.name}**${version}${duration} - ${
|
|
79
|
+
lines.push(`- **${ship.name}**${version}${duration} - ${rel}`)
|
|
82
80
|
if (ship.description) {
|
|
83
81
|
lines.push(` _${ship.description}_`)
|
|
84
82
|
}
|
|
@@ -16,13 +16,14 @@ import type {
|
|
|
16
16
|
Subtask,
|
|
17
17
|
SubtaskSummary,
|
|
18
18
|
} from '../schemas/state'
|
|
19
|
-
import {
|
|
19
|
+
import { StateJsonSchema } from '../schemas/state'
|
|
20
|
+
import { getTimestamp, toRelative } from '../utils/date-helper'
|
|
20
21
|
import { md } from '../utils/markdown-builder'
|
|
21
22
|
import { StorageManager } from './storage-manager'
|
|
22
23
|
|
|
23
24
|
class StateStorage extends StorageManager<StateJson> {
|
|
24
25
|
constructor() {
|
|
25
|
-
super('state.json')
|
|
26
|
+
super('state.json', StateJsonSchema)
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
protected getDefault(): StateJson {
|
|
@@ -52,7 +53,7 @@ class StateStorage extends StorageManager<StateJson> {
|
|
|
52
53
|
const task = data.currentTask!
|
|
53
54
|
m.bold(task.description)
|
|
54
55
|
.blank()
|
|
55
|
-
.raw(`Started: ${task.startedAt}`)
|
|
56
|
+
.raw(`Started: ${toRelative(task.startedAt)}`)
|
|
56
57
|
.raw(`Session: ${task.sessionId}`)
|
|
57
58
|
.maybe(task.featureId, (m, id) => m.raw(`Feature: ${id}`))
|
|
58
59
|
|
|
@@ -122,7 +123,7 @@ class StateStorage extends StorageManager<StateJson> {
|
|
|
122
123
|
m.hr()
|
|
123
124
|
.h2('Paused')
|
|
124
125
|
.bold(prev.description)
|
|
125
|
-
.raw(`Paused: ${prev.pausedAt}`)
|
|
126
|
+
.raw(`Paused: ${toRelative(prev.pausedAt)}`)
|
|
126
127
|
.maybe(prev.pauseReason, (m, reason) => m.raw(`Reason: ${reason}`))
|
|
127
128
|
.blank()
|
|
128
129
|
.italic('Use /p:resume to continue')
|