valija 0.1.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/LICENSE +202 -0
- package/README.md +49 -0
- package/dist/program.js +1527 -0
- package/dist/program.js.map +1 -0
- package/package.json +57 -0
package/dist/program.js
ADDED
|
@@ -0,0 +1,1527 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/delivery/cli/program.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/delivery/container.ts
|
|
7
|
+
import { ulid } from "ulid";
|
|
8
|
+
|
|
9
|
+
// src/shared/domain/result.ts
|
|
10
|
+
var ok = (value) => ({ ok: true, value });
|
|
11
|
+
var err = (error) => ({ ok: false, error });
|
|
12
|
+
var DomainError = class extends Error {
|
|
13
|
+
constructor(code, message) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.code = code;
|
|
16
|
+
this.name = "DomainError";
|
|
17
|
+
}
|
|
18
|
+
code;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// src/context/domain/errors.ts
|
|
22
|
+
var contextErr = (code, message) => err(new DomainError(code, message));
|
|
23
|
+
|
|
24
|
+
// src/context/domain/values/project-name.ts
|
|
25
|
+
var PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
26
|
+
function parseProjectName(raw) {
|
|
27
|
+
const normalized = raw.trim().toLowerCase();
|
|
28
|
+
if (!PATTERN.test(normalized)) {
|
|
29
|
+
return contextErr(
|
|
30
|
+
"INVALID_PROJECT_NAME",
|
|
31
|
+
`Project name must be 1-64 chars of [a-z0-9-], starting with a letter or digit. Got: "${raw}"`
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
return ok(normalized);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// src/context/application/export-pack.ts
|
|
38
|
+
var ExportPack = class {
|
|
39
|
+
constructor(sessions, getContextPack) {
|
|
40
|
+
this.sessions = sessions;
|
|
41
|
+
this.getContextPack = getContextPack;
|
|
42
|
+
}
|
|
43
|
+
sessions;
|
|
44
|
+
getContextPack;
|
|
45
|
+
execute(project, format) {
|
|
46
|
+
if (format === "md") {
|
|
47
|
+
const pack = this.getContextPack.execute({ project, budgetTokens: Number.MAX_SAFE_INTEGER });
|
|
48
|
+
return pack.ok ? ok(pack.value.markdown) : pack;
|
|
49
|
+
}
|
|
50
|
+
const name = parseProjectName(project);
|
|
51
|
+
if (!name.ok) return name;
|
|
52
|
+
const session = this.sessions.open();
|
|
53
|
+
if (!session.ok) return session;
|
|
54
|
+
try {
|
|
55
|
+
const found = session.value.projects.findByName(name.value);
|
|
56
|
+
if (found === null) {
|
|
57
|
+
return contextErr("PROJECT_NOT_FOUND", `No project named "${name.value}".`);
|
|
58
|
+
}
|
|
59
|
+
const items = session.value.items.findByProject(found.id).map((item) => ({
|
|
60
|
+
id: item.id,
|
|
61
|
+
type: item.type,
|
|
62
|
+
content: item.content,
|
|
63
|
+
tags: item.tags,
|
|
64
|
+
pinned: item.pinned,
|
|
65
|
+
source: item.source ?? null,
|
|
66
|
+
createdAt: item.createdAt.toISOString()
|
|
67
|
+
}));
|
|
68
|
+
return ok(JSON.stringify({ project: name.value, items }, null, 2));
|
|
69
|
+
} finally {
|
|
70
|
+
session.value.close();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// src/context/application/get-context-pack.ts
|
|
76
|
+
var DEFAULT_BUDGET_TOKENS = 4e3;
|
|
77
|
+
var estimateTokens = (text) => Math.ceil(text.length / 4);
|
|
78
|
+
var SECTION_ORDER = ["decision", "preference", "progress", "fact"];
|
|
79
|
+
var SECTION_LABELS = {
|
|
80
|
+
decision: "Decisions",
|
|
81
|
+
preference: "Preferences",
|
|
82
|
+
progress: "Progress",
|
|
83
|
+
fact: "Facts"
|
|
84
|
+
};
|
|
85
|
+
var renderItem = (item) => {
|
|
86
|
+
const date = item.createdAt.toISOString().slice(0, 10);
|
|
87
|
+
const tags = item.tags.length > 0 ? ` \xB7 ${item.tags.map((t) => `#${t}`).join(" ")}` : "";
|
|
88
|
+
return `### ${item.type} \xB7 ${date}${tags}
|
|
89
|
+
|
|
90
|
+
${item.content}
|
|
91
|
+
`;
|
|
92
|
+
};
|
|
93
|
+
var GetContextPack = class {
|
|
94
|
+
constructor(sessions, clock) {
|
|
95
|
+
this.sessions = sessions;
|
|
96
|
+
this.clock = clock;
|
|
97
|
+
}
|
|
98
|
+
sessions;
|
|
99
|
+
clock;
|
|
100
|
+
execute(input) {
|
|
101
|
+
const name = parseProjectName(input.project);
|
|
102
|
+
if (!name.ok) return name;
|
|
103
|
+
const budget = input.budgetTokens ?? DEFAULT_BUDGET_TOKENS;
|
|
104
|
+
const session = this.sessions.open();
|
|
105
|
+
if (!session.ok) return session;
|
|
106
|
+
try {
|
|
107
|
+
const project = session.value.projects.findByName(name.value);
|
|
108
|
+
if (project === null) {
|
|
109
|
+
return contextErr("PROJECT_NOT_FOUND", `No project named "${name.value}".`);
|
|
110
|
+
}
|
|
111
|
+
const all = session.value.items.findByProject(project.id);
|
|
112
|
+
const included = /* @__PURE__ */ new Set();
|
|
113
|
+
const parts = [];
|
|
114
|
+
let used = 0;
|
|
115
|
+
const header = `# Context pack: ${name.value}
|
|
116
|
+
|
|
117
|
+
> ${all.length} items in vault \xB7 generated ${this.clock.now().toISOString()}
|
|
118
|
+
`;
|
|
119
|
+
used += estimateTokens(header);
|
|
120
|
+
const pinned = all.filter((i) => i.pinned);
|
|
121
|
+
const pinnedKept = [];
|
|
122
|
+
for (const item of pinned) {
|
|
123
|
+
const cost = estimateTokens(renderItem(item));
|
|
124
|
+
if (pinnedKept.length > 0 && used + cost > budget) break;
|
|
125
|
+
pinnedKept.push(item);
|
|
126
|
+
used += cost;
|
|
127
|
+
}
|
|
128
|
+
if (pinnedKept.length > 0) {
|
|
129
|
+
parts.push("\n## Pinned\n");
|
|
130
|
+
for (const item of pinnedKept) {
|
|
131
|
+
parts.push(renderItem(item));
|
|
132
|
+
included.add(item.id);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const handoff = all.find((i) => i.type === "handoff" && !included.has(i.id));
|
|
136
|
+
if (handoff) {
|
|
137
|
+
const rendered = renderItem(handoff);
|
|
138
|
+
const cost = estimateTokens(rendered);
|
|
139
|
+
if (used + cost <= budget) {
|
|
140
|
+
parts.push("\n## Latest handoff\n", rendered);
|
|
141
|
+
included.add(handoff.id);
|
|
142
|
+
used += cost;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
for (const type of SECTION_ORDER) {
|
|
146
|
+
const items = all.filter((i) => i.type === type && !included.has(i.id));
|
|
147
|
+
let sectionOpened = false;
|
|
148
|
+
for (const item of items) {
|
|
149
|
+
const rendered = renderItem(item);
|
|
150
|
+
const sectionHeader = sectionOpened ? "" : `
|
|
151
|
+
## ${SECTION_LABELS[type]}
|
|
152
|
+
`;
|
|
153
|
+
const cost = estimateTokens(sectionHeader + rendered);
|
|
154
|
+
if (used + cost > budget) break;
|
|
155
|
+
if (!sectionOpened) {
|
|
156
|
+
parts.push(sectionHeader);
|
|
157
|
+
sectionOpened = true;
|
|
158
|
+
}
|
|
159
|
+
parts.push(rendered);
|
|
160
|
+
included.add(item.id);
|
|
161
|
+
used += cost;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const markdown = header + parts.join("\n");
|
|
165
|
+
return ok({
|
|
166
|
+
markdown,
|
|
167
|
+
includedCount: included.size,
|
|
168
|
+
totalCount: all.length,
|
|
169
|
+
estimatedTokens: estimateTokens(markdown)
|
|
170
|
+
});
|
|
171
|
+
} finally {
|
|
172
|
+
session.value.close();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// src/context/application/list-projects.ts
|
|
178
|
+
var ListProjects = class {
|
|
179
|
+
constructor(sessions) {
|
|
180
|
+
this.sessions = sessions;
|
|
181
|
+
}
|
|
182
|
+
sessions;
|
|
183
|
+
execute() {
|
|
184
|
+
const session = this.sessions.open();
|
|
185
|
+
if (!session.ok) return session;
|
|
186
|
+
try {
|
|
187
|
+
const entries = session.value.projects.list().map((summary) => ({
|
|
188
|
+
name: summary.project.name,
|
|
189
|
+
...summary.project.description === void 0 ? {} : { description: summary.project.description },
|
|
190
|
+
itemCount: summary.itemCount,
|
|
191
|
+
lastActivityAt: summary.lastActivityAt?.toISOString() ?? null
|
|
192
|
+
}));
|
|
193
|
+
return ok(entries);
|
|
194
|
+
} finally {
|
|
195
|
+
session.value.close();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
// src/context/domain/entities/context-item.ts
|
|
201
|
+
var MAX_CONTENT_BYTES = 32 * 1024;
|
|
202
|
+
function validateContent(content) {
|
|
203
|
+
const trimmed = content.trim();
|
|
204
|
+
if (trimmed.length === 0) {
|
|
205
|
+
return contextErr("CONTENT_EMPTY", "Content must not be empty.");
|
|
206
|
+
}
|
|
207
|
+
const bytes = new TextEncoder().encode(trimmed).length;
|
|
208
|
+
if (bytes > MAX_CONTENT_BYTES) {
|
|
209
|
+
return contextErr(
|
|
210
|
+
"CONTENT_TOO_LARGE",
|
|
211
|
+
`Content is ${bytes} bytes; the maximum is ${MAX_CONTENT_BYTES} (32 KB). Distill it.`
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
return ok(trimmed);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// src/context/domain/values/item-type.ts
|
|
218
|
+
var ITEM_TYPES = ["decision", "progress", "preference", "fact", "handoff"];
|
|
219
|
+
function parseItemType(raw) {
|
|
220
|
+
if (ITEM_TYPES.includes(raw)) {
|
|
221
|
+
return ok(raw);
|
|
222
|
+
}
|
|
223
|
+
return contextErr(
|
|
224
|
+
"INVALID_ITEM_TYPE",
|
|
225
|
+
`Item type must be one of ${ITEM_TYPES.join(", ")}. Got: "${raw}"`
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// src/context/domain/values/tag.ts
|
|
230
|
+
var PATTERN2 = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
|
231
|
+
var MAX_TAGS = 10;
|
|
232
|
+
function parseTag(raw) {
|
|
233
|
+
const normalized = raw.trim().toLowerCase();
|
|
234
|
+
if (!PATTERN2.test(normalized)) {
|
|
235
|
+
return contextErr("INVALID_TAG", `Tag must be 1-32 chars of [a-z0-9-]. Got: "${raw}"`);
|
|
236
|
+
}
|
|
237
|
+
return ok(normalized);
|
|
238
|
+
}
|
|
239
|
+
function parseTags(raw) {
|
|
240
|
+
if (raw.length > MAX_TAGS) {
|
|
241
|
+
return contextErr("TOO_MANY_TAGS", `At most ${MAX_TAGS} tags allowed. Got ${raw.length}.`);
|
|
242
|
+
}
|
|
243
|
+
const tags = [];
|
|
244
|
+
for (const r of raw) {
|
|
245
|
+
const parsed = parseTag(r);
|
|
246
|
+
if (!parsed.ok) return parsed;
|
|
247
|
+
if (!tags.includes(parsed.value)) tags.push(parsed.value);
|
|
248
|
+
}
|
|
249
|
+
return ok(tags);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// src/context/application/save-context.ts
|
|
253
|
+
var SaveContext = class {
|
|
254
|
+
constructor(sessions, clock, idGen) {
|
|
255
|
+
this.sessions = sessions;
|
|
256
|
+
this.clock = clock;
|
|
257
|
+
this.idGen = idGen;
|
|
258
|
+
}
|
|
259
|
+
sessions;
|
|
260
|
+
clock;
|
|
261
|
+
idGen;
|
|
262
|
+
execute(input) {
|
|
263
|
+
const name = parseProjectName(input.project);
|
|
264
|
+
if (!name.ok) return name;
|
|
265
|
+
const type = parseItemType(input.type ?? "fact");
|
|
266
|
+
if (!type.ok) return type;
|
|
267
|
+
const tags = parseTags(input.tags ?? []);
|
|
268
|
+
if (!tags.ok) return tags;
|
|
269
|
+
const content = validateContent(input.content);
|
|
270
|
+
if (!content.ok) return content;
|
|
271
|
+
const session = this.sessions.open();
|
|
272
|
+
if (!session.ok) return session;
|
|
273
|
+
try {
|
|
274
|
+
const now = this.clock.now();
|
|
275
|
+
let project = session.value.projects.findByName(name.value);
|
|
276
|
+
const projectCreated = project === null;
|
|
277
|
+
if (project === null) {
|
|
278
|
+
project = {
|
|
279
|
+
id: this.idGen.next(),
|
|
280
|
+
name: name.value,
|
|
281
|
+
createdAt: now,
|
|
282
|
+
updatedAt: now
|
|
283
|
+
};
|
|
284
|
+
session.value.projects.save(project);
|
|
285
|
+
}
|
|
286
|
+
const itemId = this.idGen.next();
|
|
287
|
+
session.value.items.save({
|
|
288
|
+
id: itemId,
|
|
289
|
+
projectId: project.id,
|
|
290
|
+
type: type.value,
|
|
291
|
+
content: content.value,
|
|
292
|
+
tags: tags.value,
|
|
293
|
+
pinned: input.pinned ?? false,
|
|
294
|
+
...input.source === void 0 ? {} : { source: input.source },
|
|
295
|
+
archived: false,
|
|
296
|
+
createdAt: now,
|
|
297
|
+
updatedAt: now
|
|
298
|
+
});
|
|
299
|
+
return ok({ itemId, project: name.value, type: type.value, projectCreated });
|
|
300
|
+
} finally {
|
|
301
|
+
session.value.close();
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
// src/context/application/search-context.ts
|
|
307
|
+
var SearchContext = class {
|
|
308
|
+
constructor(sessions) {
|
|
309
|
+
this.sessions = sessions;
|
|
310
|
+
}
|
|
311
|
+
sessions;
|
|
312
|
+
execute(input) {
|
|
313
|
+
const session = this.sessions.open();
|
|
314
|
+
if (!session.ok) return session;
|
|
315
|
+
try {
|
|
316
|
+
let projectId;
|
|
317
|
+
if (input.project !== void 0) {
|
|
318
|
+
const name = parseProjectName(input.project);
|
|
319
|
+
if (!name.ok) return name;
|
|
320
|
+
const project = session.value.projects.findByName(name.value);
|
|
321
|
+
if (project === null) {
|
|
322
|
+
return contextErr("PROJECT_NOT_FOUND", `No project named "${name.value}".`);
|
|
323
|
+
}
|
|
324
|
+
projectId = project.id;
|
|
325
|
+
}
|
|
326
|
+
const limit = Math.min(Math.max(input.limit ?? 20, 1), 100);
|
|
327
|
+
const hits = session.value.items.search(input.query, projectId, limit).map((result) => ({
|
|
328
|
+
id: result.item.id,
|
|
329
|
+
project: result.projectName,
|
|
330
|
+
type: result.item.type,
|
|
331
|
+
content: result.item.content,
|
|
332
|
+
tags: [...result.item.tags],
|
|
333
|
+
pinned: result.item.pinned,
|
|
334
|
+
createdAt: result.item.createdAt.toISOString()
|
|
335
|
+
}));
|
|
336
|
+
return ok(hits);
|
|
337
|
+
} finally {
|
|
338
|
+
session.value.close();
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
// src/context/application/show-project.ts
|
|
344
|
+
var ShowProject = class {
|
|
345
|
+
constructor(sessions) {
|
|
346
|
+
this.sessions = sessions;
|
|
347
|
+
}
|
|
348
|
+
sessions;
|
|
349
|
+
execute(project, type) {
|
|
350
|
+
const name = parseProjectName(project);
|
|
351
|
+
if (!name.ok) return name;
|
|
352
|
+
let typeValue;
|
|
353
|
+
if (type !== void 0) {
|
|
354
|
+
const parsed = parseItemType(type);
|
|
355
|
+
if (!parsed.ok) return parsed;
|
|
356
|
+
typeValue = parsed.value;
|
|
357
|
+
}
|
|
358
|
+
const session = this.sessions.open();
|
|
359
|
+
if (!session.ok) return session;
|
|
360
|
+
try {
|
|
361
|
+
const found = session.value.projects.findByName(name.value);
|
|
362
|
+
if (found === null) {
|
|
363
|
+
return contextErr("PROJECT_NOT_FOUND", `No project named "${name.value}".`);
|
|
364
|
+
}
|
|
365
|
+
const items = session.value.items.findByProject(
|
|
366
|
+
found.id,
|
|
367
|
+
typeValue === void 0 ? {} : { type: typeValue }
|
|
368
|
+
);
|
|
369
|
+
return ok(
|
|
370
|
+
items.map((item) => ({
|
|
371
|
+
id: item.id,
|
|
372
|
+
project: name.value,
|
|
373
|
+
type: item.type,
|
|
374
|
+
content: item.content,
|
|
375
|
+
tags: [...item.tags],
|
|
376
|
+
pinned: item.pinned,
|
|
377
|
+
createdAt: item.createdAt.toISOString()
|
|
378
|
+
}))
|
|
379
|
+
);
|
|
380
|
+
} finally {
|
|
381
|
+
session.value.close();
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
// src/shared/infra/migrations/001-init.ts
|
|
387
|
+
var MIGRATION_001 = `
|
|
388
|
+
CREATE TABLE projects (
|
|
389
|
+
id TEXT PRIMARY KEY,
|
|
390
|
+
name TEXT NOT NULL UNIQUE,
|
|
391
|
+
description TEXT,
|
|
392
|
+
created_at TEXT NOT NULL,
|
|
393
|
+
updated_at TEXT NOT NULL
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
CREATE TABLE context_items (
|
|
397
|
+
id TEXT PRIMARY KEY,
|
|
398
|
+
project_id TEXT NOT NULL REFERENCES projects(id),
|
|
399
|
+
type TEXT NOT NULL CHECK (type IN ('decision','progress','preference','fact','handoff')),
|
|
400
|
+
content TEXT NOT NULL,
|
|
401
|
+
tags TEXT NOT NULL DEFAULT '[]',
|
|
402
|
+
pinned INTEGER NOT NULL DEFAULT 0,
|
|
403
|
+
source TEXT,
|
|
404
|
+
archived INTEGER NOT NULL DEFAULT 0,
|
|
405
|
+
created_at TEXT NOT NULL,
|
|
406
|
+
updated_at TEXT NOT NULL
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
CREATE INDEX idx_items_project ON context_items(project_id, created_at DESC);
|
|
410
|
+
|
|
411
|
+
CREATE VIRTUAL TABLE context_items_fts USING fts5(
|
|
412
|
+
content,
|
|
413
|
+
tags,
|
|
414
|
+
content='context_items',
|
|
415
|
+
content_rowid='rowid'
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
CREATE TRIGGER items_ai AFTER INSERT ON context_items BEGIN
|
|
419
|
+
INSERT INTO context_items_fts(rowid, content, tags)
|
|
420
|
+
VALUES (new.rowid, new.content, new.tags);
|
|
421
|
+
END;
|
|
422
|
+
|
|
423
|
+
CREATE TRIGGER items_ad AFTER DELETE ON context_items BEGIN
|
|
424
|
+
INSERT INTO context_items_fts(context_items_fts, rowid, content, tags)
|
|
425
|
+
VALUES ('delete', old.rowid, old.content, old.tags);
|
|
426
|
+
END;
|
|
427
|
+
|
|
428
|
+
CREATE TRIGGER items_au AFTER UPDATE ON context_items BEGIN
|
|
429
|
+
INSERT INTO context_items_fts(context_items_fts, rowid, content, tags)
|
|
430
|
+
VALUES ('delete', old.rowid, old.content, old.tags);
|
|
431
|
+
INSERT INTO context_items_fts(rowid, content, tags)
|
|
432
|
+
VALUES (new.rowid, new.content, new.tags);
|
|
433
|
+
END;
|
|
434
|
+
`;
|
|
435
|
+
|
|
436
|
+
// src/shared/infra/migrations.ts
|
|
437
|
+
var MIGRATIONS = [
|
|
438
|
+
{ version: 1, sql: MIGRATION_001 }
|
|
439
|
+
];
|
|
440
|
+
function migrate(db) {
|
|
441
|
+
db.exec("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)");
|
|
442
|
+
const row = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
|
|
443
|
+
const current = row ? Number(row.value) : 0;
|
|
444
|
+
for (const migration of MIGRATIONS) {
|
|
445
|
+
if (migration.version <= current) continue;
|
|
446
|
+
db.transaction(() => {
|
|
447
|
+
db.exec(migration.sql);
|
|
448
|
+
db.prepare(
|
|
449
|
+
"INSERT INTO meta (key, value) VALUES ('schema_version', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value"
|
|
450
|
+
).run(String(migration.version));
|
|
451
|
+
})();
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// src/shared/infra/sqlite.ts
|
|
456
|
+
import { mkdirSync } from "fs";
|
|
457
|
+
import { dirname } from "path";
|
|
458
|
+
import SqliteDatabase from "better-sqlite3-multiple-ciphers";
|
|
459
|
+
function openVaultDb(dbPath, keyHex) {
|
|
460
|
+
if (!/^[0-9a-f]{64}$/i.test(keyHex)) {
|
|
461
|
+
throw new Error("Vault key must be 64 hex characters (32 bytes).");
|
|
462
|
+
}
|
|
463
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
464
|
+
const db = new SqliteDatabase(dbPath);
|
|
465
|
+
try {
|
|
466
|
+
db.pragma("cipher='sqlcipher'");
|
|
467
|
+
db.pragma(`key="x'${keyHex}'"`);
|
|
468
|
+
db.prepare("SELECT count(*) FROM sqlite_master").get();
|
|
469
|
+
db.pragma("journal_mode = WAL");
|
|
470
|
+
db.pragma("foreign_keys = ON");
|
|
471
|
+
return db;
|
|
472
|
+
} catch (error) {
|
|
473
|
+
db.close();
|
|
474
|
+
throw error;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
function isWrongKeyError(error) {
|
|
478
|
+
return error instanceof Error && "code" in error && error.code === "SQLITE_NOTADB";
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// src/vault/domain/errors.ts
|
|
482
|
+
var vaultErr = (code, message) => err(new DomainError(code, message));
|
|
483
|
+
|
|
484
|
+
// src/vault/infra/vault-header.ts
|
|
485
|
+
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
486
|
+
import { z } from "zod";
|
|
487
|
+
var headerSchema = z.object({
|
|
488
|
+
vaultId: z.string().min(1),
|
|
489
|
+
schemaVersion: z.literal(1),
|
|
490
|
+
kdf: z.object({
|
|
491
|
+
algorithm: z.literal("argon2id"),
|
|
492
|
+
memoryKiB: z.number().int().positive(),
|
|
493
|
+
iterations: z.number().int().positive(),
|
|
494
|
+
parallelism: z.number().int().positive()
|
|
495
|
+
}),
|
|
496
|
+
saltBase64: z.string().min(1),
|
|
497
|
+
createdAt: z.string()
|
|
498
|
+
});
|
|
499
|
+
function writeVaultHeader(path, header) {
|
|
500
|
+
const json = {
|
|
501
|
+
vaultId: header.vaultId,
|
|
502
|
+
schemaVersion: header.schemaVersion,
|
|
503
|
+
kdf: header.kdf,
|
|
504
|
+
saltBase64: Buffer.from(header.salt).toString("base64"),
|
|
505
|
+
createdAt: header.createdAt
|
|
506
|
+
};
|
|
507
|
+
writeFileSync(path, `${JSON.stringify(json, null, 2)}
|
|
508
|
+
`, "utf8");
|
|
509
|
+
}
|
|
510
|
+
function readVaultHeader(path) {
|
|
511
|
+
if (!existsSync(path)) {
|
|
512
|
+
return vaultErr("VAULT_NOT_FOUND", `No vault header found at ${path}. Run "valija init".`);
|
|
513
|
+
}
|
|
514
|
+
let raw;
|
|
515
|
+
try {
|
|
516
|
+
raw = JSON.parse(readFileSync(path, "utf8"));
|
|
517
|
+
} catch {
|
|
518
|
+
return vaultErr("STORAGE_ERROR", `Vault header at ${path} is not valid JSON.`);
|
|
519
|
+
}
|
|
520
|
+
const parsed = headerSchema.safeParse(raw);
|
|
521
|
+
if (!parsed.success) {
|
|
522
|
+
return vaultErr(
|
|
523
|
+
"STORAGE_ERROR",
|
|
524
|
+
`Vault header at ${path} is malformed: ${parsed.error.message}`
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
const h = parsed.data;
|
|
528
|
+
return ok({
|
|
529
|
+
vaultId: h.vaultId,
|
|
530
|
+
schemaVersion: h.schemaVersion,
|
|
531
|
+
kdf: h.kdf,
|
|
532
|
+
salt: new Uint8Array(Buffer.from(h.saltBase64, "base64")),
|
|
533
|
+
createdAt: h.createdAt
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// src/context/infra/item-repo.ts
|
|
538
|
+
var toItem = (row) => ({
|
|
539
|
+
id: row.id,
|
|
540
|
+
projectId: row.project_id,
|
|
541
|
+
type: row.type,
|
|
542
|
+
content: row.content,
|
|
543
|
+
tags: JSON.parse(row.tags),
|
|
544
|
+
pinned: row.pinned === 1,
|
|
545
|
+
...row.source === null ? {} : { source: row.source },
|
|
546
|
+
archived: row.archived === 1,
|
|
547
|
+
createdAt: new Date(row.created_at),
|
|
548
|
+
updatedAt: new Date(row.updated_at)
|
|
549
|
+
});
|
|
550
|
+
var toFtsQuery = (query) => query.split(/\s+/).filter(Boolean).map((term) => `"${term.replace(/"/g, '""')}"`).join(" ");
|
|
551
|
+
var SqliteContextItemRepository = class {
|
|
552
|
+
constructor(db) {
|
|
553
|
+
this.db = db;
|
|
554
|
+
}
|
|
555
|
+
db;
|
|
556
|
+
save(item) {
|
|
557
|
+
this.db.prepare(
|
|
558
|
+
`INSERT INTO context_items
|
|
559
|
+
(id, project_id, type, content, tags, pinned, source, archived, created_at, updated_at)
|
|
560
|
+
VALUES (@id, @projectId, @type, @content, @tags, @pinned, @source, @archived, @createdAt, @updatedAt)
|
|
561
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
562
|
+
type = excluded.type,
|
|
563
|
+
content = excluded.content,
|
|
564
|
+
tags = excluded.tags,
|
|
565
|
+
pinned = excluded.pinned,
|
|
566
|
+
source = excluded.source,
|
|
567
|
+
archived = excluded.archived,
|
|
568
|
+
updated_at = excluded.updated_at`
|
|
569
|
+
).run({
|
|
570
|
+
id: item.id,
|
|
571
|
+
projectId: item.projectId,
|
|
572
|
+
type: item.type,
|
|
573
|
+
content: item.content,
|
|
574
|
+
tags: JSON.stringify(item.tags),
|
|
575
|
+
pinned: item.pinned ? 1 : 0,
|
|
576
|
+
source: item.source ?? null,
|
|
577
|
+
archived: item.archived ? 1 : 0,
|
|
578
|
+
createdAt: item.createdAt.toISOString(),
|
|
579
|
+
updatedAt: item.updatedAt.toISOString()
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
findByProject(projectId, filters = {}) {
|
|
583
|
+
const conditions = ["project_id = @projectId"];
|
|
584
|
+
const params = { projectId };
|
|
585
|
+
if (!filters.includeArchived) conditions.push("archived = 0");
|
|
586
|
+
if (filters.type !== void 0) {
|
|
587
|
+
conditions.push("type = @type");
|
|
588
|
+
params.type = filters.type;
|
|
589
|
+
}
|
|
590
|
+
if (filters.pinned !== void 0) {
|
|
591
|
+
conditions.push("pinned = @pinned");
|
|
592
|
+
params.pinned = filters.pinned ? 1 : 0;
|
|
593
|
+
}
|
|
594
|
+
const limit = filters.limit !== void 0 ? ` LIMIT ${Math.max(0, filters.limit)}` : "";
|
|
595
|
+
const rows = this.db.prepare(
|
|
596
|
+
`SELECT * FROM context_items WHERE ${conditions.join(" AND ")}
|
|
597
|
+
ORDER BY created_at DESC${limit}`
|
|
598
|
+
).all(params);
|
|
599
|
+
return rows.map(toItem);
|
|
600
|
+
}
|
|
601
|
+
search(query, projectId, limit = 20) {
|
|
602
|
+
const fts = toFtsQuery(query);
|
|
603
|
+
if (fts.length === 0) return [];
|
|
604
|
+
const projectFilter = projectId !== void 0 ? "AND i.project_id = @projectId" : "";
|
|
605
|
+
const rows = this.db.prepare(
|
|
606
|
+
`SELECT i.*, p.name AS project_name
|
|
607
|
+
FROM context_items_fts f
|
|
608
|
+
JOIN context_items i ON i.rowid = f.rowid
|
|
609
|
+
JOIN projects p ON p.id = i.project_id
|
|
610
|
+
WHERE context_items_fts MATCH @fts AND i.archived = 0 ${projectFilter}
|
|
611
|
+
ORDER BY rank LIMIT @limit`
|
|
612
|
+
).all({ fts, projectId, limit });
|
|
613
|
+
return rows.map((row) => ({
|
|
614
|
+
item: toItem(row),
|
|
615
|
+
projectName: row.project_name
|
|
616
|
+
}));
|
|
617
|
+
}
|
|
618
|
+
archive(itemId) {
|
|
619
|
+
const result = this.db.prepare("UPDATE context_items SET archived = 1 WHERE id = ?").run(itemId);
|
|
620
|
+
return result.changes > 0;
|
|
621
|
+
}
|
|
622
|
+
};
|
|
623
|
+
|
|
624
|
+
// src/context/infra/project-repo.ts
|
|
625
|
+
var toProject = (row) => ({
|
|
626
|
+
id: row.id,
|
|
627
|
+
name: row.name,
|
|
628
|
+
...row.description === null ? {} : { description: row.description },
|
|
629
|
+
createdAt: new Date(row.created_at),
|
|
630
|
+
updatedAt: new Date(row.updated_at)
|
|
631
|
+
});
|
|
632
|
+
var SqliteProjectRepository = class {
|
|
633
|
+
constructor(db) {
|
|
634
|
+
this.db = db;
|
|
635
|
+
}
|
|
636
|
+
db;
|
|
637
|
+
save(project) {
|
|
638
|
+
this.db.prepare(
|
|
639
|
+
`INSERT INTO projects (id, name, description, created_at, updated_at)
|
|
640
|
+
VALUES (@id, @name, @description, @createdAt, @updatedAt)
|
|
641
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
642
|
+
name = excluded.name,
|
|
643
|
+
description = excluded.description,
|
|
644
|
+
updated_at = excluded.updated_at`
|
|
645
|
+
).run({
|
|
646
|
+
id: project.id,
|
|
647
|
+
name: project.name,
|
|
648
|
+
description: project.description ?? null,
|
|
649
|
+
createdAt: project.createdAt.toISOString(),
|
|
650
|
+
updatedAt: project.updatedAt.toISOString()
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
findByName(name) {
|
|
654
|
+
const row = this.db.prepare("SELECT * FROM projects WHERE name = ?").get(name);
|
|
655
|
+
return row ? toProject(row) : null;
|
|
656
|
+
}
|
|
657
|
+
list() {
|
|
658
|
+
const rows = this.db.prepare(
|
|
659
|
+
`SELECT p.*,
|
|
660
|
+
COUNT(i.id) AS item_count,
|
|
661
|
+
MAX(i.updated_at) AS last_activity
|
|
662
|
+
FROM projects p
|
|
663
|
+
LEFT JOIN context_items i ON i.project_id = p.id AND i.archived = 0
|
|
664
|
+
GROUP BY p.id
|
|
665
|
+
ORDER BY last_activity DESC NULLS LAST, p.name ASC`
|
|
666
|
+
).all();
|
|
667
|
+
return rows.map((row) => ({
|
|
668
|
+
project: toProject(row),
|
|
669
|
+
itemCount: row.item_count,
|
|
670
|
+
lastActivityAt: row.last_activity ? new Date(row.last_activity) : null
|
|
671
|
+
}));
|
|
672
|
+
}
|
|
673
|
+
};
|
|
674
|
+
|
|
675
|
+
// src/context/infra/session-factory.ts
|
|
676
|
+
var LOCKED_MESSAGE = 'Vault is locked. Ask the user to run "valija unlock" in a terminal.';
|
|
677
|
+
var SqliteVaultSessionFactory = class {
|
|
678
|
+
constructor(paths, keychain) {
|
|
679
|
+
this.paths = paths;
|
|
680
|
+
this.keychain = keychain;
|
|
681
|
+
}
|
|
682
|
+
paths;
|
|
683
|
+
keychain;
|
|
684
|
+
open() {
|
|
685
|
+
const header = readVaultHeader(this.paths.header);
|
|
686
|
+
if (!header.ok) return header;
|
|
687
|
+
const keyHex = this.keychain.getKey(header.value.vaultId);
|
|
688
|
+
if (keyHex === null) return vaultErr("VAULT_LOCKED", LOCKED_MESSAGE);
|
|
689
|
+
try {
|
|
690
|
+
const db = openVaultDb(this.paths.db, keyHex);
|
|
691
|
+
migrate(db);
|
|
692
|
+
return ok({
|
|
693
|
+
projects: new SqliteProjectRepository(db),
|
|
694
|
+
items: new SqliteContextItemRepository(db),
|
|
695
|
+
close: () => db.close()
|
|
696
|
+
});
|
|
697
|
+
} catch (error) {
|
|
698
|
+
if (isWrongKeyError(error)) {
|
|
699
|
+
this.keychain.deleteKey(header.value.vaultId);
|
|
700
|
+
return vaultErr("VAULT_LOCKED", LOCKED_MESSAGE);
|
|
701
|
+
}
|
|
702
|
+
return vaultErr("STORAGE_ERROR", `Could not open vault: ${error.message}`);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
};
|
|
706
|
+
|
|
707
|
+
// src/shared/infra/vault-paths.ts
|
|
708
|
+
import { homedir } from "os";
|
|
709
|
+
import { join } from "path";
|
|
710
|
+
function resolveVaultPaths(rootOverride) {
|
|
711
|
+
const root = rootOverride ?? process.env.VALIJA_HOME ?? join(homedir(), ".valija");
|
|
712
|
+
return {
|
|
713
|
+
root,
|
|
714
|
+
header: join(root, "vault.json"),
|
|
715
|
+
db: join(root, "vault.db")
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// src/vault/application/hex.ts
|
|
720
|
+
var bytesToHex = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
721
|
+
var isKeyHex = (value) => /^[0-9a-f]{64}$/i.test(value);
|
|
722
|
+
|
|
723
|
+
// src/vault/application/ports/crypto.ts
|
|
724
|
+
var DEFAULT_KDF_PARAMS = {
|
|
725
|
+
algorithm: "argon2id",
|
|
726
|
+
memoryKiB: 64 * 1024,
|
|
727
|
+
iterations: 3,
|
|
728
|
+
parallelism: 1
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
// src/vault/application/create-vault.ts
|
|
732
|
+
var MIN_PASSPHRASE_LENGTH = 8;
|
|
733
|
+
var CreateVault = class {
|
|
734
|
+
constructor(store, crypto, keychain, clock, idGen) {
|
|
735
|
+
this.store = store;
|
|
736
|
+
this.crypto = crypto;
|
|
737
|
+
this.keychain = keychain;
|
|
738
|
+
this.clock = clock;
|
|
739
|
+
this.idGen = idGen;
|
|
740
|
+
}
|
|
741
|
+
store;
|
|
742
|
+
crypto;
|
|
743
|
+
keychain;
|
|
744
|
+
clock;
|
|
745
|
+
idGen;
|
|
746
|
+
async execute(passphrase) {
|
|
747
|
+
if (this.store.headerExists()) {
|
|
748
|
+
return vaultErr("VAULT_ALREADY_EXISTS", "A vault already exists on this machine.");
|
|
749
|
+
}
|
|
750
|
+
if (passphrase.length < MIN_PASSPHRASE_LENGTH) {
|
|
751
|
+
return vaultErr(
|
|
752
|
+
"WEAK_PASSPHRASE",
|
|
753
|
+
`Passphrase must be at least ${MIN_PASSPHRASE_LENGTH} characters.`
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
const vaultId = this.idGen.next();
|
|
757
|
+
const salt = this.crypto.generateSalt();
|
|
758
|
+
const key = await this.crypto.deriveKey(passphrase, salt, DEFAULT_KDF_PARAMS);
|
|
759
|
+
const keyHex = bytesToHex(key);
|
|
760
|
+
const createdAt = this.clock.now().toISOString();
|
|
761
|
+
this.store.writeHeader({ vaultId, schemaVersion: 1, kdf: DEFAULT_KDF_PARAMS, salt, createdAt });
|
|
762
|
+
const init = this.store.initializeDb(keyHex);
|
|
763
|
+
if (!init.ok) return init;
|
|
764
|
+
this.keychain.setKey(vaultId, keyHex);
|
|
765
|
+
return ok({ vaultId, keyHex, createdAt });
|
|
766
|
+
}
|
|
767
|
+
};
|
|
768
|
+
|
|
769
|
+
// src/vault/application/lock-vault.ts
|
|
770
|
+
var LockVault = class {
|
|
771
|
+
constructor(store, keychain) {
|
|
772
|
+
this.store = store;
|
|
773
|
+
this.keychain = keychain;
|
|
774
|
+
}
|
|
775
|
+
store;
|
|
776
|
+
keychain;
|
|
777
|
+
execute() {
|
|
778
|
+
const header = this.store.readHeader();
|
|
779
|
+
if (!header.ok) return header;
|
|
780
|
+
const wasUnlocked = this.keychain.deleteKey(header.value.vaultId);
|
|
781
|
+
return ok({ wasUnlocked });
|
|
782
|
+
}
|
|
783
|
+
};
|
|
784
|
+
|
|
785
|
+
// src/vault/application/unlock-vault.ts
|
|
786
|
+
var UnlockVault = class {
|
|
787
|
+
constructor(store, crypto, keychain) {
|
|
788
|
+
this.store = store;
|
|
789
|
+
this.crypto = crypto;
|
|
790
|
+
this.keychain = keychain;
|
|
791
|
+
}
|
|
792
|
+
store;
|
|
793
|
+
crypto;
|
|
794
|
+
keychain;
|
|
795
|
+
async execute(input) {
|
|
796
|
+
const header = this.store.readHeader();
|
|
797
|
+
if (!header.ok) return header;
|
|
798
|
+
let keyHex;
|
|
799
|
+
if (input.recoveryKeyHex !== void 0) {
|
|
800
|
+
if (!isKeyHex(input.recoveryKeyHex)) {
|
|
801
|
+
return vaultErr("WRONG_PASSPHRASE", "Recovery key must be 64 hex characters.");
|
|
802
|
+
}
|
|
803
|
+
keyHex = input.recoveryKeyHex.toLowerCase();
|
|
804
|
+
} else if (input.passphrase !== void 0) {
|
|
805
|
+
const key = await this.crypto.deriveKey(
|
|
806
|
+
input.passphrase,
|
|
807
|
+
header.value.salt,
|
|
808
|
+
header.value.kdf
|
|
809
|
+
);
|
|
810
|
+
keyHex = bytesToHex(key);
|
|
811
|
+
} else {
|
|
812
|
+
return vaultErr("WRONG_PASSPHRASE", "Provide a passphrase or a recovery key.");
|
|
813
|
+
}
|
|
814
|
+
const verified = this.store.verifyKey(keyHex);
|
|
815
|
+
if (!verified.ok) return verified;
|
|
816
|
+
this.keychain.setKey(header.value.vaultId, keyHex);
|
|
817
|
+
return ok({ vaultId: header.value.vaultId });
|
|
818
|
+
}
|
|
819
|
+
};
|
|
820
|
+
|
|
821
|
+
// src/vault/application/vault-status.ts
|
|
822
|
+
var VaultStatus = class {
|
|
823
|
+
constructor(store, keychain) {
|
|
824
|
+
this.store = store;
|
|
825
|
+
this.keychain = keychain;
|
|
826
|
+
}
|
|
827
|
+
store;
|
|
828
|
+
keychain;
|
|
829
|
+
execute() {
|
|
830
|
+
const dbPath = this.store.dbPath();
|
|
831
|
+
if (!this.store.headerExists()) {
|
|
832
|
+
return ok({ initialized: false, unlocked: false, dbPath });
|
|
833
|
+
}
|
|
834
|
+
const header = this.store.readHeader();
|
|
835
|
+
if (!header.ok) return header;
|
|
836
|
+
const keyHex = this.keychain.getKey(header.value.vaultId);
|
|
837
|
+
const unlocked = keyHex !== null && this.store.verifyKey(keyHex).ok;
|
|
838
|
+
return ok({ initialized: true, unlocked, vaultId: header.value.vaultId, dbPath });
|
|
839
|
+
}
|
|
840
|
+
};
|
|
841
|
+
|
|
842
|
+
// src/vault/infra/argon2.ts
|
|
843
|
+
import { randomBytes } from "crypto";
|
|
844
|
+
import argon2 from "argon2";
|
|
845
|
+
var KEY_LENGTH = 32;
|
|
846
|
+
var SALT_LENGTH = 16;
|
|
847
|
+
var Argon2VaultCrypto = class {
|
|
848
|
+
async deriveKey(passphrase, salt, params) {
|
|
849
|
+
const key = await argon2.hash(passphrase, {
|
|
850
|
+
type: argon2.argon2id,
|
|
851
|
+
raw: true,
|
|
852
|
+
salt: Buffer.from(salt),
|
|
853
|
+
memoryCost: params.memoryKiB,
|
|
854
|
+
timeCost: params.iterations,
|
|
855
|
+
parallelism: params.parallelism,
|
|
856
|
+
hashLength: KEY_LENGTH
|
|
857
|
+
});
|
|
858
|
+
return new Uint8Array(key);
|
|
859
|
+
}
|
|
860
|
+
generateSalt() {
|
|
861
|
+
return new Uint8Array(randomBytes(SALT_LENGTH));
|
|
862
|
+
}
|
|
863
|
+
};
|
|
864
|
+
|
|
865
|
+
// src/vault/infra/file-vault-store.ts
|
|
866
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync2 } from "fs";
|
|
867
|
+
var FileVaultStore = class {
|
|
868
|
+
constructor(paths) {
|
|
869
|
+
this.paths = paths;
|
|
870
|
+
}
|
|
871
|
+
paths;
|
|
872
|
+
headerExists() {
|
|
873
|
+
return existsSync2(this.paths.header);
|
|
874
|
+
}
|
|
875
|
+
readHeader() {
|
|
876
|
+
return readVaultHeader(this.paths.header);
|
|
877
|
+
}
|
|
878
|
+
writeHeader(header) {
|
|
879
|
+
mkdirSync2(this.paths.root, { recursive: true });
|
|
880
|
+
writeVaultHeader(this.paths.header, header);
|
|
881
|
+
}
|
|
882
|
+
initializeDb(keyHex) {
|
|
883
|
+
try {
|
|
884
|
+
const db = openVaultDb(this.paths.db, keyHex);
|
|
885
|
+
migrate(db);
|
|
886
|
+
db.close();
|
|
887
|
+
return ok(void 0);
|
|
888
|
+
} catch (error) {
|
|
889
|
+
return vaultErr("STORAGE_ERROR", `Could not create vault db: ${error.message}`);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
verifyKey(keyHex) {
|
|
893
|
+
try {
|
|
894
|
+
const db = openVaultDb(this.paths.db, keyHex);
|
|
895
|
+
db.close();
|
|
896
|
+
return ok(void 0);
|
|
897
|
+
} catch (error) {
|
|
898
|
+
if (isWrongKeyError(error)) {
|
|
899
|
+
return vaultErr("WRONG_PASSPHRASE", "Wrong passphrase (or recovery key) for this vault.");
|
|
900
|
+
}
|
|
901
|
+
return vaultErr("STORAGE_ERROR", `Could not open vault db: ${error.message}`);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
dbPath() {
|
|
905
|
+
return this.paths.db;
|
|
906
|
+
}
|
|
907
|
+
};
|
|
908
|
+
|
|
909
|
+
// src/vault/infra/keyring.ts
|
|
910
|
+
import { Entry } from "@napi-rs/keyring";
|
|
911
|
+
var SERVICE = "valija";
|
|
912
|
+
var OsKeychain = class {
|
|
913
|
+
setKey(vaultId, keyHex) {
|
|
914
|
+
new Entry(SERVICE, vaultId).setPassword(keyHex);
|
|
915
|
+
}
|
|
916
|
+
getKey(vaultId) {
|
|
917
|
+
try {
|
|
918
|
+
return new Entry(SERVICE, vaultId).getPassword();
|
|
919
|
+
} catch {
|
|
920
|
+
return null;
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
deleteKey(vaultId) {
|
|
924
|
+
try {
|
|
925
|
+
return new Entry(SERVICE, vaultId).deletePassword();
|
|
926
|
+
} catch {
|
|
927
|
+
return false;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
};
|
|
931
|
+
|
|
932
|
+
// src/delivery/container.ts
|
|
933
|
+
var systemClock = { now: () => /* @__PURE__ */ new Date() };
|
|
934
|
+
var ulidIds = { next: () => ulid() };
|
|
935
|
+
function buildContainer() {
|
|
936
|
+
const paths = resolveVaultPaths();
|
|
937
|
+
const store = new FileVaultStore(paths);
|
|
938
|
+
const crypto = new Argon2VaultCrypto();
|
|
939
|
+
const keychain = new OsKeychain();
|
|
940
|
+
const sessions = new SqliteVaultSessionFactory(paths, keychain);
|
|
941
|
+
const getContextPack = new GetContextPack(sessions, systemClock);
|
|
942
|
+
return {
|
|
943
|
+
paths,
|
|
944
|
+
createVault: new CreateVault(store, crypto, keychain, systemClock, ulidIds),
|
|
945
|
+
unlockVault: new UnlockVault(store, crypto, keychain),
|
|
946
|
+
lockVault: new LockVault(store, keychain),
|
|
947
|
+
vaultStatus: new VaultStatus(store, keychain),
|
|
948
|
+
saveContext: new SaveContext(sessions, systemClock, ulidIds),
|
|
949
|
+
listProjects: new ListProjects(sessions),
|
|
950
|
+
searchContext: new SearchContext(sessions),
|
|
951
|
+
getContextPack,
|
|
952
|
+
exportPack: new ExportPack(sessions, getContextPack),
|
|
953
|
+
showProject: new ShowProject(sessions)
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// src/delivery/mcp/server.ts
|
|
958
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
959
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
960
|
+
import { z as z2 } from "zod";
|
|
961
|
+
|
|
962
|
+
// src/delivery/version.ts
|
|
963
|
+
var VERSION = "0.1.0";
|
|
964
|
+
|
|
965
|
+
// src/delivery/mcp/server.ts
|
|
966
|
+
var ok2 = (text) => ({ content: [{ type: "text", text }] });
|
|
967
|
+
var err2 = (error) => ({
|
|
968
|
+
content: [{ type: "text", text: `${error.message} [${error.code}]` }],
|
|
969
|
+
isError: true
|
|
970
|
+
});
|
|
971
|
+
var DESCRIPTIONS = {
|
|
972
|
+
save_context: "Save durable context about a project for future sessions in any AI tool. Use when the user asks to save or remember something, or at the end of a working session. Write `content` so it is self-contained and specific for a reader with no other context: decisions made and why, current state, conventions and preferences established, next steps, unresolved questions. Never include secrets, API keys, or passwords.",
|
|
973
|
+
save_handoff: "Package the state of THIS conversation so another AI tool or session can continue seamlessly. Structure `content` as: goal, what was done, current blockers, exact next step, relevant files or links.",
|
|
974
|
+
get_context: "Load the saved context pack for a project before starting work on it. Returns pinned and recent context (decisions, preferences, progress, latest handoff) as markdown within a token budget. Call this when the user asks to load, resume, or continue a project.",
|
|
975
|
+
search_context: "Full-text search across saved context. Use for specific recall questions such as 'what did we decide about auth?'. Returns matching items with project, type, and date.",
|
|
976
|
+
list_projects: "List existing projects with item counts and last activity. Use to disambiguate which project the user means before saving or loading."
|
|
977
|
+
};
|
|
978
|
+
function buildMcpServer(c) {
|
|
979
|
+
const server = new McpServer({ name: "valija", version: VERSION });
|
|
980
|
+
const clientName = () => server.server.getClientVersion()?.name;
|
|
981
|
+
server.registerTool(
|
|
982
|
+
"save_context",
|
|
983
|
+
{
|
|
984
|
+
title: "Save context",
|
|
985
|
+
description: DESCRIPTIONS.save_context,
|
|
986
|
+
inputSchema: {
|
|
987
|
+
project: z2.string().describe("Project slug, e.g. 'vault-app'. Created if it does not exist."),
|
|
988
|
+
content: z2.string().describe("Self-contained markdown. Max 32 KB."),
|
|
989
|
+
type: z2.enum(ITEM_TYPES).optional().describe("Kind of context. Defaults to 'fact'."),
|
|
990
|
+
tags: z2.array(z2.string()).max(10).optional().describe("Lowercase tags for recall.")
|
|
991
|
+
}
|
|
992
|
+
},
|
|
993
|
+
async ({ project, content, type, tags }) => {
|
|
994
|
+
const source = clientName();
|
|
995
|
+
const r = c.saveContext.execute({
|
|
996
|
+
project,
|
|
997
|
+
content,
|
|
998
|
+
...type === void 0 ? {} : { type },
|
|
999
|
+
...tags === void 0 ? {} : { tags },
|
|
1000
|
+
...source === void 0 ? {} : { source }
|
|
1001
|
+
});
|
|
1002
|
+
if (!r.ok) return err2(r.error);
|
|
1003
|
+
return ok2(
|
|
1004
|
+
`Saved ${r.value.type} to project "${r.value.project}"${r.value.projectCreated ? " (project created)" : ""}. Item id: ${r.value.itemId}.`
|
|
1005
|
+
);
|
|
1006
|
+
}
|
|
1007
|
+
);
|
|
1008
|
+
server.registerTool(
|
|
1009
|
+
"save_handoff",
|
|
1010
|
+
{
|
|
1011
|
+
title: "Save handoff",
|
|
1012
|
+
description: DESCRIPTIONS.save_handoff,
|
|
1013
|
+
inputSchema: {
|
|
1014
|
+
project: z2.string().describe("Project slug. Created if it does not exist."),
|
|
1015
|
+
content: z2.string().describe("Goal, what was done, blockers, exact next step, relevant files or links.")
|
|
1016
|
+
}
|
|
1017
|
+
},
|
|
1018
|
+
async ({ project, content }) => {
|
|
1019
|
+
const source = clientName();
|
|
1020
|
+
const r = c.saveContext.execute({
|
|
1021
|
+
project,
|
|
1022
|
+
content,
|
|
1023
|
+
type: "handoff",
|
|
1024
|
+
...source === void 0 ? {} : { source }
|
|
1025
|
+
});
|
|
1026
|
+
if (!r.ok) return err2(r.error);
|
|
1027
|
+
return ok2(`Handoff saved to project "${r.value.project}". Item id: ${r.value.itemId}.`);
|
|
1028
|
+
}
|
|
1029
|
+
);
|
|
1030
|
+
server.registerTool(
|
|
1031
|
+
"get_context",
|
|
1032
|
+
{
|
|
1033
|
+
title: "Get context pack",
|
|
1034
|
+
description: DESCRIPTIONS.get_context,
|
|
1035
|
+
inputSchema: {
|
|
1036
|
+
project: z2.string().describe("Project slug to load."),
|
|
1037
|
+
budget: z2.number().int().positive().optional().describe("Token budget. Default 4000.")
|
|
1038
|
+
}
|
|
1039
|
+
},
|
|
1040
|
+
async ({ project, budget }) => {
|
|
1041
|
+
const r = c.getContextPack.execute({
|
|
1042
|
+
project,
|
|
1043
|
+
...budget === void 0 ? {} : { budgetTokens: budget }
|
|
1044
|
+
});
|
|
1045
|
+
if (!r.ok) return err2(r.error);
|
|
1046
|
+
return ok2(r.value.markdown);
|
|
1047
|
+
}
|
|
1048
|
+
);
|
|
1049
|
+
server.registerTool(
|
|
1050
|
+
"search_context",
|
|
1051
|
+
{
|
|
1052
|
+
title: "Search context",
|
|
1053
|
+
description: DESCRIPTIONS.search_context,
|
|
1054
|
+
inputSchema: {
|
|
1055
|
+
query: z2.string().describe("Full-text query, e.g. 'auth decision'."),
|
|
1056
|
+
project: z2.string().optional().describe("Limit to one project."),
|
|
1057
|
+
limit: z2.number().int().positive().max(100).optional().describe("Max results. Default 20.")
|
|
1058
|
+
}
|
|
1059
|
+
},
|
|
1060
|
+
async ({ query, project, limit }) => {
|
|
1061
|
+
const r = c.searchContext.execute({
|
|
1062
|
+
query,
|
|
1063
|
+
...project === void 0 ? {} : { project },
|
|
1064
|
+
...limit === void 0 ? {} : { limit }
|
|
1065
|
+
});
|
|
1066
|
+
if (!r.ok) return err2(r.error);
|
|
1067
|
+
if (r.value.length === 0) return ok2("No matches.");
|
|
1068
|
+
const lines = r.value.map(
|
|
1069
|
+
(hit) => `- [${hit.project}] (${hit.type}, ${hit.createdAt.slice(0, 10)}) ${hit.content}`
|
|
1070
|
+
);
|
|
1071
|
+
return ok2(`${r.value.length} match(es):
|
|
1072
|
+
|
|
1073
|
+
${lines.join("\n")}`);
|
|
1074
|
+
}
|
|
1075
|
+
);
|
|
1076
|
+
server.registerTool(
|
|
1077
|
+
"list_projects",
|
|
1078
|
+
{
|
|
1079
|
+
title: "List projects",
|
|
1080
|
+
description: DESCRIPTIONS.list_projects,
|
|
1081
|
+
inputSchema: {}
|
|
1082
|
+
},
|
|
1083
|
+
async () => {
|
|
1084
|
+
const r = c.listProjects.execute();
|
|
1085
|
+
if (!r.ok) return err2(r.error);
|
|
1086
|
+
if (r.value.length === 0) return ok2("No projects in the vault yet.");
|
|
1087
|
+
const lines = r.value.map(
|
|
1088
|
+
(p) => `- ${p.name}: ${p.itemCount} item(s), last activity ${p.lastActivityAt?.slice(0, 10) ?? "never"}`
|
|
1089
|
+
);
|
|
1090
|
+
return ok2(lines.join("\n"));
|
|
1091
|
+
}
|
|
1092
|
+
);
|
|
1093
|
+
server.registerPrompt(
|
|
1094
|
+
"save-context",
|
|
1095
|
+
{
|
|
1096
|
+
title: "Save session context to valija",
|
|
1097
|
+
description: "Distill this session into a self-contained context save.",
|
|
1098
|
+
argsSchema: { project: z2.string().optional() }
|
|
1099
|
+
},
|
|
1100
|
+
({ project }) => ({
|
|
1101
|
+
messages: [
|
|
1102
|
+
{
|
|
1103
|
+
role: "user",
|
|
1104
|
+
content: {
|
|
1105
|
+
type: "text",
|
|
1106
|
+
text: `Review this conversation and save its durable context to valija${project ? ` under the project "${project}"` : ""}.
|
|
1107
|
+
|
|
1108
|
+
1. If the project is ambiguous, call list_projects first and confirm with me.
|
|
1109
|
+
2. Compose content that is self-contained for a reader with no other context: decisions made and why, current state, conventions/preferences established, next steps, unresolved questions. No secrets, API keys, or passwords.
|
|
1110
|
+
3. Pick the right type (decision/progress/preference/fact) and 1-5 tags.
|
|
1111
|
+
4. Call save_context, then tell me what you saved in one line.`
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
]
|
|
1115
|
+
})
|
|
1116
|
+
);
|
|
1117
|
+
server.registerPrompt(
|
|
1118
|
+
"load-context",
|
|
1119
|
+
{
|
|
1120
|
+
title: "Load project context from valija",
|
|
1121
|
+
description: "Load a project's context pack and resume work.",
|
|
1122
|
+
argsSchema: { project: z2.string() }
|
|
1123
|
+
},
|
|
1124
|
+
({ project }) => ({
|
|
1125
|
+
messages: [
|
|
1126
|
+
{
|
|
1127
|
+
role: "user",
|
|
1128
|
+
content: {
|
|
1129
|
+
type: "text",
|
|
1130
|
+
text: `Call get_context for the project "${project}". Then:
|
|
1131
|
+
1. Summarize the current state in 3 lines max.
|
|
1132
|
+
2. Propose the next step based on the latest handoff or progress.
|
|
1133
|
+
Do not repeat the whole pack back to me.`
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
]
|
|
1137
|
+
})
|
|
1138
|
+
);
|
|
1139
|
+
return server;
|
|
1140
|
+
}
|
|
1141
|
+
async function runMcpServer(c) {
|
|
1142
|
+
const server = buildMcpServer(c);
|
|
1143
|
+
await server.connect(new StdioServerTransport());
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// src/delivery/cli/content-commands.ts
|
|
1147
|
+
import { writeFileSync as writeFileSync2 } from "fs";
|
|
1148
|
+
|
|
1149
|
+
// src/delivery/cli/render.ts
|
|
1150
|
+
function fail(error) {
|
|
1151
|
+
console.error(`error [${error.code}]: ${error.message}`);
|
|
1152
|
+
process.exit(1);
|
|
1153
|
+
}
|
|
1154
|
+
function truncate(text, max = 80) {
|
|
1155
|
+
const oneLine = text.replace(/\s+/g, " ").trim();
|
|
1156
|
+
return oneLine.length <= max ? oneLine : `${oneLine.slice(0, max - 1)}\u2026`;
|
|
1157
|
+
}
|
|
1158
|
+
function formatDate(iso) {
|
|
1159
|
+
return iso === null ? "\u2014" : iso.slice(0, 10);
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
// src/delivery/cli/content-commands.ts
|
|
1163
|
+
function projectsCommand(c) {
|
|
1164
|
+
const result = c.listProjects.execute();
|
|
1165
|
+
if (!result.ok) fail(result.error);
|
|
1166
|
+
if (result.value.length === 0) {
|
|
1167
|
+
console.log(
|
|
1168
|
+
"No projects yet. Save context from an AI tool, or check back after your first session."
|
|
1169
|
+
);
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1172
|
+
console.log("PROJECT ITEMS LAST ACTIVITY");
|
|
1173
|
+
for (const p of result.value) {
|
|
1174
|
+
console.log(
|
|
1175
|
+
`${p.name.padEnd(32)} ${String(p.itemCount).padStart(5)} ${formatDate(p.lastActivityAt)}`
|
|
1176
|
+
);
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
function showCommand(c, project, options) {
|
|
1180
|
+
const result = c.showProject.execute(project, options.type);
|
|
1181
|
+
if (!result.ok) fail(result.error);
|
|
1182
|
+
if (result.value.length === 0) {
|
|
1183
|
+
console.log("No items.");
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
for (const item of result.value) {
|
|
1187
|
+
const pin = item.pinned ? " \u{1F4CC}" : "";
|
|
1188
|
+
const tags = item.tags.length > 0 ? ` [${item.tags.join(", ")}]` : "";
|
|
1189
|
+
console.log(`
|
|
1190
|
+
\u2014 ${item.type} \xB7 ${formatDate(item.createdAt)}${pin}${tags}`);
|
|
1191
|
+
console.log(item.content);
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
function searchCommand(c, query, options) {
|
|
1195
|
+
const result = c.searchContext.execute({
|
|
1196
|
+
query,
|
|
1197
|
+
...options.project === void 0 ? {} : { project: options.project }
|
|
1198
|
+
});
|
|
1199
|
+
if (!result.ok) fail(result.error);
|
|
1200
|
+
if (result.value.length === 0) {
|
|
1201
|
+
console.log("No matches.");
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1204
|
+
for (const hit of result.value) {
|
|
1205
|
+
console.log(
|
|
1206
|
+
`${hit.project} ${hit.type.padEnd(10)} ${formatDate(hit.createdAt)} ${truncate(hit.content)}`
|
|
1207
|
+
);
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
function exportCommand(c, project, options) {
|
|
1211
|
+
const format = options.json ? "json" : "md";
|
|
1212
|
+
const result = c.exportPack.execute(project, format);
|
|
1213
|
+
if (!result.ok) fail(result.error);
|
|
1214
|
+
if (options.output !== void 0) {
|
|
1215
|
+
writeFileSync2(options.output, result.value, "utf8");
|
|
1216
|
+
console.error(`Exported ${project} (${format}) to ${options.output}`);
|
|
1217
|
+
} else {
|
|
1218
|
+
console.log(result.value);
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
// src/delivery/cli/doctor.ts
|
|
1223
|
+
import { existsSync as existsSync4, readFileSync as readFileSync3 } from "fs";
|
|
1224
|
+
|
|
1225
|
+
// src/delivery/cli/installer.ts
|
|
1226
|
+
import { copyFileSync, existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync2, writeFileSync as writeFileSync3 } from "fs";
|
|
1227
|
+
import { homedir as homedir2 } from "os";
|
|
1228
|
+
import { dirname as dirname2, join as join2 } from "path";
|
|
1229
|
+
var CLIENTS = ["claude-code", "claude-desktop", "cursor"];
|
|
1230
|
+
var MCP_ENTRY = { command: "npx", args: ["-y", "valija", "mcp"] };
|
|
1231
|
+
function clientConfigPath(client, platform = process.platform) {
|
|
1232
|
+
const home = homedir2();
|
|
1233
|
+
switch (client) {
|
|
1234
|
+
case "claude-code":
|
|
1235
|
+
return join2(home, ".claude.json");
|
|
1236
|
+
case "cursor":
|
|
1237
|
+
return join2(home, ".cursor", "mcp.json");
|
|
1238
|
+
case "claude-desktop": {
|
|
1239
|
+
if (platform === "win32")
|
|
1240
|
+
return join2(
|
|
1241
|
+
process.env.APPDATA ?? join2(home, "AppData", "Roaming"),
|
|
1242
|
+
"Claude",
|
|
1243
|
+
"claude_desktop_config.json"
|
|
1244
|
+
);
|
|
1245
|
+
if (platform === "darwin")
|
|
1246
|
+
return join2(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
1247
|
+
return join2(home, ".config", "Claude", "claude_desktop_config.json");
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
function installIntoClient(client) {
|
|
1252
|
+
const configPath = clientConfigPath(client);
|
|
1253
|
+
let existing = {};
|
|
1254
|
+
let backupPath = null;
|
|
1255
|
+
if (existsSync3(configPath)) {
|
|
1256
|
+
const raw = readFileSync2(configPath, "utf8");
|
|
1257
|
+
const parsed = JSON.parse(raw);
|
|
1258
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
1259
|
+
throw new Error(`${configPath} does not contain a JSON object; not touching it.`);
|
|
1260
|
+
}
|
|
1261
|
+
existing = parsed;
|
|
1262
|
+
backupPath = `${configPath}.backup-${Date.now()}`;
|
|
1263
|
+
copyFileSync(configPath, backupPath);
|
|
1264
|
+
} else {
|
|
1265
|
+
mkdirSync3(dirname2(configPath), { recursive: true });
|
|
1266
|
+
}
|
|
1267
|
+
const servers = typeof existing.mcpServers === "object" && existing.mcpServers !== null ? existing.mcpServers : {};
|
|
1268
|
+
const merged = { ...existing, mcpServers: { ...servers, valija: MCP_ENTRY } };
|
|
1269
|
+
writeFileSync3(configPath, `${JSON.stringify(merged, null, 2)}
|
|
1270
|
+
`, "utf8");
|
|
1271
|
+
return { configPath, backupPath };
|
|
1272
|
+
}
|
|
1273
|
+
function manualInstructions(client) {
|
|
1274
|
+
return `Add this to the "mcpServers" object of ${clientConfigPath(client)}:
|
|
1275
|
+
|
|
1276
|
+
"valija": ${JSON.stringify(MCP_ENTRY, null, 2).replace(/\n/g, "\n ")}
|
|
1277
|
+
`;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
// src/delivery/cli/doctor.ts
|
|
1281
|
+
async function doctorCommand(c) {
|
|
1282
|
+
const checks = [];
|
|
1283
|
+
const [major] = process.versions.node.split(".").map(Number);
|
|
1284
|
+
checks.push({
|
|
1285
|
+
name: "node",
|
|
1286
|
+
ok: (major ?? 0) >= 22,
|
|
1287
|
+
detail: `v${process.versions.node} (need >=22)`,
|
|
1288
|
+
fatal: true
|
|
1289
|
+
});
|
|
1290
|
+
try {
|
|
1291
|
+
const { default: Db } = await import("better-sqlite3-multiple-ciphers");
|
|
1292
|
+
const db = new Db(":memory:");
|
|
1293
|
+
db.pragma("cipher='sqlcipher'");
|
|
1294
|
+
db.close();
|
|
1295
|
+
checks.push({ name: "sqlcipher", ok: true, detail: "native module loads" });
|
|
1296
|
+
} catch (e) {
|
|
1297
|
+
checks.push({ name: "sqlcipher", ok: false, detail: e.message, fatal: true });
|
|
1298
|
+
}
|
|
1299
|
+
try {
|
|
1300
|
+
const keychain = new OsKeychain();
|
|
1301
|
+
keychain.setKey("doctor-probe", "test");
|
|
1302
|
+
const roundtrip = keychain.getKey("doctor-probe") === "test";
|
|
1303
|
+
keychain.deleteKey("doctor-probe");
|
|
1304
|
+
checks.push({ name: "keychain", ok: roundtrip, detail: "OS keychain read/write" });
|
|
1305
|
+
} catch (e) {
|
|
1306
|
+
checks.push({ name: "keychain", ok: false, detail: e.message });
|
|
1307
|
+
}
|
|
1308
|
+
const status = c.vaultStatus.execute();
|
|
1309
|
+
if (status.ok) {
|
|
1310
|
+
checks.push({
|
|
1311
|
+
name: "vault",
|
|
1312
|
+
ok: status.value.initialized,
|
|
1313
|
+
detail: status.value.initialized ? `${status.value.unlocked ? "unlocked" : "locked"} at ${status.value.dbPath}` : 'not initialized \u2014 run "valija init"'
|
|
1314
|
+
});
|
|
1315
|
+
} else {
|
|
1316
|
+
checks.push({ name: "vault", ok: false, detail: status.error.message });
|
|
1317
|
+
}
|
|
1318
|
+
for (const client of CLIENTS) {
|
|
1319
|
+
const path = clientConfigPath(client);
|
|
1320
|
+
let detail = "config not found";
|
|
1321
|
+
let installed = false;
|
|
1322
|
+
if (existsSync4(path)) {
|
|
1323
|
+
detail = "config found, valija not installed";
|
|
1324
|
+
try {
|
|
1325
|
+
const parsed = JSON.parse(readFileSync3(path, "utf8"));
|
|
1326
|
+
installed = parsed.mcpServers?.valija !== void 0;
|
|
1327
|
+
if (installed) detail = "valija installed";
|
|
1328
|
+
} catch {
|
|
1329
|
+
detail = "config exists but is not valid JSON";
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
checks.push({ name: client, ok: installed, detail });
|
|
1333
|
+
}
|
|
1334
|
+
let fatal = false;
|
|
1335
|
+
for (const check of checks) {
|
|
1336
|
+
console.log(`${check.ok ? "\u2713" : "\u2717"} ${check.name.padEnd(16)} ${check.detail}`);
|
|
1337
|
+
if (!check.ok && check.fatal) fatal = true;
|
|
1338
|
+
}
|
|
1339
|
+
if (fatal) process.exit(1);
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
// src/vault/infra/recovery-kit.ts
|
|
1343
|
+
function renderRecoveryKit(vaultId, keyHex, createdAt) {
|
|
1344
|
+
return `================================================================
|
|
1345
|
+
VALIJA RECOVERY KIT
|
|
1346
|
+
================================================================
|
|
1347
|
+
|
|
1348
|
+
Created: ${createdAt}
|
|
1349
|
+
Vault id: ${vaultId}
|
|
1350
|
+
|
|
1351
|
+
RAW ENCRYPTION KEY (hex):
|
|
1352
|
+
|
|
1353
|
+
${keyHex}
|
|
1354
|
+
|
|
1355
|
+
----------------------------------------------------------------
|
|
1356
|
+
WHAT THIS IS
|
|
1357
|
+
|
|
1358
|
+
This key decrypts your valija vault (~/.valija/vault.db) WITHOUT
|
|
1359
|
+
the passphrase. It is the only way back in if you forget your
|
|
1360
|
+
passphrase. There is no server, no account, no reset email.
|
|
1361
|
+
|
|
1362
|
+
passphrase lost + this kit lost = your data is gone. Forever.
|
|
1363
|
+
|
|
1364
|
+
WHAT TO DO WITH IT
|
|
1365
|
+
|
|
1366
|
+
1. Print this file or copy it by hand.
|
|
1367
|
+
2. Store it OFFLINE: a drawer, a safe, a password manager you
|
|
1368
|
+
trust \u2014 anywhere that is not this computer.
|
|
1369
|
+
3. Delete this file from disk once stored.
|
|
1370
|
+
|
|
1371
|
+
HOW TO RECOVER
|
|
1372
|
+
|
|
1373
|
+
valija unlock --recovery-key <the hex above>
|
|
1374
|
+
|
|
1375
|
+
Anyone holding this key can read your vault. Guard it like a key.
|
|
1376
|
+
================================================================
|
|
1377
|
+
`;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// src/delivery/cli/prompt.ts
|
|
1381
|
+
import { createInterface } from "readline";
|
|
1382
|
+
var CTRL_C = String.fromCharCode(3);
|
|
1383
|
+
var BACKSPACE = String.fromCharCode(8);
|
|
1384
|
+
var DEL = String.fromCharCode(127);
|
|
1385
|
+
var reader;
|
|
1386
|
+
var bufferedLines = [];
|
|
1387
|
+
var waiters = [];
|
|
1388
|
+
function readPipedLine() {
|
|
1389
|
+
if (reader === void 0) {
|
|
1390
|
+
reader = createInterface({ input: process.stdin });
|
|
1391
|
+
reader.on("line", (line) => {
|
|
1392
|
+
const waiter = waiters.shift();
|
|
1393
|
+
if (waiter) waiter(line);
|
|
1394
|
+
else bufferedLines.push(line);
|
|
1395
|
+
if (waiters.length === 0) reader?.pause();
|
|
1396
|
+
});
|
|
1397
|
+
}
|
|
1398
|
+
const queued = bufferedLines.shift();
|
|
1399
|
+
if (queued !== void 0) return Promise.resolve(queued);
|
|
1400
|
+
reader.resume();
|
|
1401
|
+
return new Promise((resolve) => waiters.push(resolve));
|
|
1402
|
+
}
|
|
1403
|
+
function readHiddenTtyLine() {
|
|
1404
|
+
return new Promise((resolve) => {
|
|
1405
|
+
const { stdin, stdout } = process;
|
|
1406
|
+
const chars = [];
|
|
1407
|
+
const onData = (buf) => {
|
|
1408
|
+
for (const c of buf.toString("utf8")) {
|
|
1409
|
+
if (c === "\r" || c === "\n") {
|
|
1410
|
+
stdin.setRawMode(false);
|
|
1411
|
+
stdin.pause();
|
|
1412
|
+
stdin.off("data", onData);
|
|
1413
|
+
stdout.write("\n");
|
|
1414
|
+
resolve(chars.join(""));
|
|
1415
|
+
return;
|
|
1416
|
+
}
|
|
1417
|
+
if (c === CTRL_C) {
|
|
1418
|
+
stdin.setRawMode(false);
|
|
1419
|
+
stdout.write("\n");
|
|
1420
|
+
process.exit(130);
|
|
1421
|
+
}
|
|
1422
|
+
if (c === BACKSPACE || c === DEL) chars.pop();
|
|
1423
|
+
else chars.push(c);
|
|
1424
|
+
}
|
|
1425
|
+
};
|
|
1426
|
+
stdin.setRawMode(true);
|
|
1427
|
+
stdin.resume();
|
|
1428
|
+
stdin.on("data", onData);
|
|
1429
|
+
});
|
|
1430
|
+
}
|
|
1431
|
+
function promptHidden(question) {
|
|
1432
|
+
process.stdout.write(question);
|
|
1433
|
+
return process.stdin.isTTY === true ? readHiddenTtyLine() : readPipedLine();
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
// src/delivery/cli/vault-commands.ts
|
|
1437
|
+
async function initCommand(c) {
|
|
1438
|
+
console.log("Creating your encrypted vault.\n");
|
|
1439
|
+
console.log("Choose a passphrase (min 8 chars). You will need it to unlock the vault.");
|
|
1440
|
+
console.log("If you lose it AND the recovery kit, your data is gone. No reset exists.\n");
|
|
1441
|
+
const passphrase = await promptHidden("Passphrase: ");
|
|
1442
|
+
const confirmation = await promptHidden("Repeat passphrase: ");
|
|
1443
|
+
if (passphrase !== confirmation) {
|
|
1444
|
+
console.error("error: passphrases do not match.");
|
|
1445
|
+
process.exit(1);
|
|
1446
|
+
}
|
|
1447
|
+
console.log("\nDeriving key (Argon2id, ~1s)...");
|
|
1448
|
+
const result = await c.createVault.execute(passphrase);
|
|
1449
|
+
if (!result.ok) fail(result.error);
|
|
1450
|
+
const kit = renderRecoveryKit(result.value.vaultId, result.value.keyHex, result.value.createdAt);
|
|
1451
|
+
console.log(`
|
|
1452
|
+
Vault created at ${c.paths.root} and unlocked.
|
|
1453
|
+
`);
|
|
1454
|
+
console.log(kit);
|
|
1455
|
+
console.log("^ THIS IS YOUR RECOVERY KIT \u2014 it is shown ONCE and never stored.");
|
|
1456
|
+
console.log(" Copy it somewhere safe (offline) before you close this terminal.\n");
|
|
1457
|
+
console.log('Next: run "valija install claude-code" (or claude-desktop, cursor).');
|
|
1458
|
+
}
|
|
1459
|
+
async function unlockCommand(c, options) {
|
|
1460
|
+
const input = options.recoveryKey !== void 0 ? { recoveryKeyHex: options.recoveryKey } : { passphrase: await promptHidden("Passphrase: ") };
|
|
1461
|
+
const result = await c.unlockVault.execute(input);
|
|
1462
|
+
if (!result.ok) fail(result.error);
|
|
1463
|
+
console.log("Vault unlocked. MCP tools can now read and write context.");
|
|
1464
|
+
console.log('Lock it again with "valija lock".');
|
|
1465
|
+
}
|
|
1466
|
+
function lockCommand(c) {
|
|
1467
|
+
const result = c.lockVault.execute();
|
|
1468
|
+
if (!result.ok) fail(result.error);
|
|
1469
|
+
console.log(result.value.wasUnlocked ? "Vault locked." : "Vault was already locked.");
|
|
1470
|
+
}
|
|
1471
|
+
function statusCommand(c) {
|
|
1472
|
+
const result = c.vaultStatus.execute();
|
|
1473
|
+
if (!result.ok) fail(result.error);
|
|
1474
|
+
const s = result.value;
|
|
1475
|
+
if (!s.initialized) {
|
|
1476
|
+
console.log('No vault on this machine. Run "valija init" to create one.');
|
|
1477
|
+
return;
|
|
1478
|
+
}
|
|
1479
|
+
console.log(`vault: ${s.dbPath}`);
|
|
1480
|
+
console.log(`vault id: ${s.vaultId}`);
|
|
1481
|
+
console.log(`state: ${s.unlocked ? "UNLOCKED" : "LOCKED"}`);
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
// src/delivery/cli/program.ts
|
|
1485
|
+
var program = new Command();
|
|
1486
|
+
var container = buildContainer();
|
|
1487
|
+
program.name("valija").description("Encrypted context vault for developers who use several AI tools.").version(VERSION);
|
|
1488
|
+
program.command("init").description("Create the encrypted vault (passphrase + recovery kit).").action(() => initCommand(container));
|
|
1489
|
+
program.command("unlock").description("Unlock the vault for this session (key goes to the OS keychain).").option("--recovery-key <hex>", "unlock with the raw key from your recovery kit").action((options) => unlockCommand(container, options));
|
|
1490
|
+
program.command("lock").description("Lock the vault (remove the key from the OS keychain).").action(() => lockCommand(container));
|
|
1491
|
+
program.command("status").description("Show vault location and lock state.").action(() => statusCommand(container));
|
|
1492
|
+
program.command("projects").description("List projects with item counts and last activity.").action(() => projectsCommand(container));
|
|
1493
|
+
program.command("show").description("Print the items of a project.").argument("<project>", "project slug").option("--type <type>", "filter by type (decision|progress|preference|fact|handoff)").action(
|
|
1494
|
+
(project, options) => showCommand(container, project, options)
|
|
1495
|
+
);
|
|
1496
|
+
program.command("search").description("Full-text search across saved context.").argument("<query>", "search terms").option("-p, --project <project>", "limit to one project").action(
|
|
1497
|
+
(query, options) => searchCommand(container, query, options)
|
|
1498
|
+
);
|
|
1499
|
+
program.command("export").description("Export a project's context pack (markdown by default).").argument("<project>", "project slug").option("--json", "export as JSON instead of markdown").option("-o, --output <file>", "write to a file instead of stdout").action(
|
|
1500
|
+
(project, options) => exportCommand(container, project, options)
|
|
1501
|
+
);
|
|
1502
|
+
program.command("install").description("Wire the valija MCP server into an AI tool's config.").argument("<client>", `one of: ${CLIENTS.join(", ")}`).action((client) => {
|
|
1503
|
+
if (!CLIENTS.includes(client)) {
|
|
1504
|
+
console.error(`error: unknown client "${client}". Use one of: ${CLIENTS.join(", ")}`);
|
|
1505
|
+
process.exit(1);
|
|
1506
|
+
}
|
|
1507
|
+
try {
|
|
1508
|
+
const result = installIntoClient(client);
|
|
1509
|
+
console.log(`valija MCP server added to ${result.configPath}`);
|
|
1510
|
+
if (result.backupPath) console.log(`Backup of the previous config: ${result.backupPath}`);
|
|
1511
|
+
console.log(`Restart ${client} to pick it up.`);
|
|
1512
|
+
} catch (e) {
|
|
1513
|
+
console.error(`Could not update the config automatically: ${e.message}
|
|
1514
|
+
`);
|
|
1515
|
+
console.error(manualInstructions(client));
|
|
1516
|
+
process.exit(1);
|
|
1517
|
+
}
|
|
1518
|
+
});
|
|
1519
|
+
program.command("mcp").description("Run the MCP server on stdio (used by AI tools, not by humans).").action(async () => {
|
|
1520
|
+
await runMcpServer(container);
|
|
1521
|
+
});
|
|
1522
|
+
program.command("doctor").description("Check environment, vault, keychain, and client configs.").action(() => doctorCommand(container));
|
|
1523
|
+
program.parseAsync().catch((error) => {
|
|
1524
|
+
console.error(`error: ${error.message}`);
|
|
1525
|
+
process.exit(1);
|
|
1526
|
+
});
|
|
1527
|
+
//# sourceMappingURL=program.js.map
|