@ivotoby/postgram-cli 1.0.5 → 1.0.6

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/dist/client.js ADDED
@@ -0,0 +1,174 @@
1
+ import { AppError, ErrorCode } from './errors.js';
2
+ async function parseResponse(response) {
3
+ const contentType = response.headers.get('content-type') ?? '';
4
+ const payload = contentType.includes('application/json')
5
+ ? (await response.json())
6
+ : (await response.text());
7
+ return payload;
8
+ }
9
+ function normalizeErrorBody(body) {
10
+ if (!body || typeof body !== 'object') {
11
+ return {};
12
+ }
13
+ return body;
14
+ }
15
+ async function request(options, path, requestOptions = {}) {
16
+ const headers = {
17
+ Authorization: `Bearer ${options.apiKey}`,
18
+ ...(requestOptions.body === undefined
19
+ ? {}
20
+ : {
21
+ 'Content-Type': 'application/json'
22
+ })
23
+ };
24
+ const init = {
25
+ method: requestOptions.method ?? 'GET',
26
+ headers
27
+ };
28
+ if (requestOptions.body !== undefined) {
29
+ init.body = JSON.stringify(requestOptions.body);
30
+ }
31
+ const response = await fetch(`${options.apiUrl}${path}`, init);
32
+ if (!response.ok) {
33
+ const body = normalizeErrorBody(await parseResponse(response));
34
+ throw new AppError(body.error?.code ?? ErrorCode.INTERNAL, body.error?.message ?? `Request failed with status ${response.status}`, body.error?.details ?? {});
35
+ }
36
+ return parseResponse(response);
37
+ }
38
+ export function createPgmClient(options) {
39
+ return {
40
+ storeEntity(input) {
41
+ return request(options, '/api/entities', {
42
+ method: 'POST',
43
+ body: input
44
+ });
45
+ },
46
+ recallEntity(id, input = {}) {
47
+ const params = new URLSearchParams();
48
+ if (input.owner) {
49
+ params.set('owner', input.owner);
50
+ }
51
+ const query = params.toString();
52
+ return request(options, `/api/entities/${id}${query ? `?${query}` : ''}`);
53
+ },
54
+ searchEntities(input) {
55
+ return request(options, '/api/search', {
56
+ method: 'POST',
57
+ body: input
58
+ });
59
+ },
60
+ updateEntity(id, input) {
61
+ return request(options, `/api/entities/${id}`, {
62
+ method: 'PATCH',
63
+ body: input
64
+ });
65
+ },
66
+ deleteEntity(id) {
67
+ return request(options, `/api/entities/${id}`, {
68
+ method: 'DELETE'
69
+ });
70
+ },
71
+ listEntities(input = {}) {
72
+ const params = new URLSearchParams();
73
+ if (input.type) {
74
+ params.set('type', input.type);
75
+ }
76
+ if (input.status) {
77
+ params.set('status', input.status);
78
+ }
79
+ if (input.visibility) {
80
+ params.set('visibility', input.visibility);
81
+ }
82
+ if (input.owner) {
83
+ params.set('owner', input.owner);
84
+ }
85
+ if (input.tags?.length) {
86
+ params.set('tags', input.tags.join(','));
87
+ }
88
+ if (input.limit !== undefined) {
89
+ params.set('limit', String(input.limit));
90
+ }
91
+ if (input.offset !== undefined) {
92
+ params.set('offset', String(input.offset));
93
+ }
94
+ const query = params.toString();
95
+ return request(options, `/api/entities${query ? `?${query}` : ''}`);
96
+ },
97
+ createTask(input) {
98
+ return request(options, '/api/tasks', {
99
+ method: 'POST',
100
+ body: input
101
+ });
102
+ },
103
+ listTasks(input = {}) {
104
+ const params = new URLSearchParams();
105
+ if (input.status) {
106
+ params.set('status', input.status);
107
+ }
108
+ if (input.context) {
109
+ params.set('context', input.context);
110
+ }
111
+ if (input.limit !== undefined) {
112
+ params.set('limit', String(input.limit));
113
+ }
114
+ if (input.offset !== undefined) {
115
+ params.set('offset', String(input.offset));
116
+ }
117
+ return request(options, `/api/tasks?${params.toString()}`);
118
+ },
119
+ updateTask(id, input) {
120
+ return request(options, `/api/tasks/${id}`, {
121
+ method: 'PATCH',
122
+ body: input
123
+ });
124
+ },
125
+ completeTask(id, version) {
126
+ return request(options, `/api/tasks/${id}/complete`, {
127
+ method: 'POST',
128
+ body: { version }
129
+ });
130
+ },
131
+ syncRepo(input) {
132
+ return request(options, '/api/sync', {
133
+ method: 'POST',
134
+ body: input
135
+ });
136
+ },
137
+ getSyncStatus(repo) {
138
+ return request(options, `/api/sync/status/${encodeURIComponent(repo)}`);
139
+ },
140
+ createEdge(input) {
141
+ return request(options, '/api/edges', {
142
+ method: 'POST',
143
+ body: input
144
+ });
145
+ },
146
+ deleteEdge(id) {
147
+ return request(options, `/api/edges/${id}`, {
148
+ method: 'DELETE'
149
+ });
150
+ },
151
+ listEdges(entityId, params = {}) {
152
+ const qs = new URLSearchParams();
153
+ if (params.relation)
154
+ qs.set('relation', params.relation);
155
+ if (params.direction)
156
+ qs.set('direction', params.direction);
157
+ if (params.owner)
158
+ qs.set('owner', params.owner);
159
+ const query = qs.toString();
160
+ return request(options, `/api/entities/${entityId}/edges${query ? `?${query}` : ''}`);
161
+ },
162
+ expandGraph(entityId, params = {}) {
163
+ const qs = new URLSearchParams();
164
+ if (params.depth !== undefined)
165
+ qs.set('depth', String(params.depth));
166
+ if (params.relationTypes?.length)
167
+ qs.set('relation_types', params.relationTypes.join(','));
168
+ if (params.owner)
169
+ qs.set('owner', params.owner);
170
+ const query = qs.toString();
171
+ return request(options, `/api/entities/${entityId}/graph${query ? `?${query}` : ''}`);
172
+ }
173
+ };
174
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,32 @@
1
+ // Standalone error types for the CLI package.
2
+ // This breaks the circular dependency that exists in the server package
3
+ // between src/util/errors.ts and src/types/api.ts.
4
+ export var ErrorCode;
5
+ (function (ErrorCode) {
6
+ ErrorCode["VALIDATION"] = "VALIDATION";
7
+ ErrorCode["UNAUTHORIZED"] = "UNAUTHORIZED";
8
+ ErrorCode["FORBIDDEN"] = "FORBIDDEN";
9
+ ErrorCode["NOT_FOUND"] = "NOT_FOUND";
10
+ ErrorCode["CONFLICT"] = "CONFLICT";
11
+ ErrorCode["EMBEDDING_FAILED"] = "EMBEDDING_FAILED";
12
+ ErrorCode["INTERNAL"] = "INTERNAL";
13
+ })(ErrorCode || (ErrorCode = {}));
14
+ export class AppError extends Error {
15
+ code;
16
+ details;
17
+ constructor(code, message, details = {}) {
18
+ super(message);
19
+ this.name = 'AppError';
20
+ this.code = code;
21
+ this.details = details;
22
+ }
23
+ }
24
+ export function toErrorResponse(error) {
25
+ return {
26
+ error: {
27
+ code: error.code,
28
+ message: error.message,
29
+ details: error.details
30
+ }
31
+ };
32
+ }
package/dist/pgm.js ADDED
@@ -0,0 +1,646 @@
1
+ #!/usr/bin/env node
2
+ import { spawn, spawnSync } from 'node:child_process';
3
+ import { createHash } from 'node:crypto';
4
+ import { createWriteStream } from 'node:fs';
5
+ import { mkdir, readdir, readFile as fsReadFile, stat } from 'node:fs/promises';
6
+ import path from 'node:path';
7
+ import { Command } from 'commander';
8
+ import { pipeline } from 'node:stream/promises';
9
+ import { createPgmClient } from './client.js';
10
+ import { handleCliFailure, isJsonMode, parseCommaList, parseJsonObject, printHuman, printJson, readStdinText, resolvePgmConfig, shortId } from './shared.js';
11
+ import { AppError, ErrorCode } from './errors.js';
12
+ function formatStoredEntity(entity) {
13
+ return [
14
+ `${entity.type} ${shortId(entity.id)}${entity.status ? ` [${entity.status}]` : ''}`,
15
+ `visibility: ${entity.visibility}`,
16
+ `owner: ${entity.owner ?? 'shared'}`,
17
+ `tags: ${entity.tags.join(', ') || '-'}`,
18
+ entity.content ? `content: ${entity.content}` : 'content: -'
19
+ ];
20
+ }
21
+ function formatSearchResults(results) {
22
+ if (results.length === 0) {
23
+ return ['No results'];
24
+ }
25
+ const lines = [];
26
+ for (const result of results) {
27
+ lines.push(`${result.entity.type} ${shortId(result.entity.id)} score=${result.score.toFixed(3)}`);
28
+ lines.push(` ${result.chunk_content}`);
29
+ if (result.entity.content && result.entity.content !== result.chunk_content) {
30
+ lines.push(` entity: ${result.entity.content}`);
31
+ }
32
+ }
33
+ return lines;
34
+ }
35
+ function formatTaskList(items) {
36
+ if (items.length === 0) {
37
+ return ['No tasks'];
38
+ }
39
+ return items.flatMap((item) => {
40
+ const metadata = item.metadata;
41
+ const suffix = [
42
+ metadata.context ? `context=${metadata.context}` : undefined,
43
+ metadata.due_date ? `due=${metadata.due_date}` : undefined
44
+ ]
45
+ .filter(Boolean)
46
+ .join(' ');
47
+ return [
48
+ `${shortId(item.id)}${item.status ? ` [${item.status}]` : ''} ${item.content ?? ''}`.trim(),
49
+ suffix ? ` ${suffix}` : ' -'
50
+ ];
51
+ });
52
+ }
53
+ async function resolveStoreContent(content) {
54
+ if (content !== undefined) {
55
+ return content;
56
+ }
57
+ const stdin = await readStdinText();
58
+ if (!stdin) {
59
+ throw new AppError(ErrorCode.VALIDATION, 'content is required');
60
+ }
61
+ return stdin;
62
+ }
63
+ async function runWithClient(command, handler) {
64
+ const json = isJsonMode(command);
65
+ try {
66
+ const config = await resolvePgmConfig();
67
+ const client = createPgmClient(config);
68
+ const result = await handler(client, json);
69
+ if (result !== undefined) {
70
+ if (json) {
71
+ printJson(result);
72
+ }
73
+ else if (Array.isArray(result)) {
74
+ printHuman(result.map(String));
75
+ }
76
+ else {
77
+ printHuman([String(result)]);
78
+ }
79
+ }
80
+ }
81
+ catch (error) {
82
+ await handleCliFailure(error, json);
83
+ }
84
+ }
85
+ async function runBackup(output, encrypt) {
86
+ if (!output) {
87
+ throw new AppError(ErrorCode.VALIDATION, '--output is required for backup');
88
+ }
89
+ const databaseUrl = process.env.DATABASE_URL ?? process.env.PGM_DATABASE_URL;
90
+ if (!databaseUrl) {
91
+ throw new AppError(ErrorCode.VALIDATION, 'DATABASE_URL must be set for backup');
92
+ }
93
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
94
+ let destination = output;
95
+ const backupExtension = encrypt ? '.dump.gpg' : '.dump';
96
+ try {
97
+ const fileStat = await stat(output);
98
+ if (fileStat.isDirectory()) {
99
+ destination = path.join(output, `postgram-backup-${timestamp}${backupExtension}`);
100
+ }
101
+ }
102
+ catch (error) {
103
+ if (error instanceof Error &&
104
+ 'code' in error &&
105
+ error.code === 'ENOENT' &&
106
+ output.endsWith('/')) {
107
+ destination = path.join(output, `postgram-backup-${timestamp}${backupExtension}`);
108
+ }
109
+ }
110
+ await mkdir(path.dirname(destination), { recursive: true });
111
+ const hasLocalPgDump = spawnSync('sh', ['-lc', 'command -v pg_dump >/dev/null']).status === 0;
112
+ const dockerService = process.env.PGM_BACKUP_DOCKER_SERVICE ?? 'postgres';
113
+ const dockerUser = process.env.PGM_BACKUP_DOCKER_USER ?? 'postgram';
114
+ const dockerDatabase = process.env.PGM_BACKUP_DOCKER_DB ?? 'postgram';
115
+ const pgDump = hasLocalPgDump
116
+ ? spawn('pg_dump', [
117
+ '--dbname',
118
+ databaseUrl,
119
+ '--format=custom',
120
+ '--no-owner',
121
+ '--no-privileges'
122
+ ])
123
+ : spawn('docker', [
124
+ 'compose',
125
+ 'exec',
126
+ '-T',
127
+ dockerService,
128
+ 'pg_dump',
129
+ '-U',
130
+ dockerUser,
131
+ '-d',
132
+ dockerDatabase,
133
+ '--format=custom',
134
+ '--no-owner',
135
+ '--no-privileges'
136
+ ]);
137
+ if (encrypt) {
138
+ const passphrase = process.env.PGM_BACKUP_PASSPHRASE;
139
+ if (!passphrase) {
140
+ throw new AppError(ErrorCode.VALIDATION, 'PGM_BACKUP_PASSPHRASE must be set when using --encrypt');
141
+ }
142
+ const gpg = spawn('gpg', [
143
+ '--symmetric',
144
+ '--cipher-algo',
145
+ 'AES256',
146
+ '--batch',
147
+ '--yes',
148
+ '--pinentry-mode',
149
+ 'loopback',
150
+ '--passphrase',
151
+ passphrase,
152
+ '--output',
153
+ destination
154
+ ]);
155
+ const waitForExit = (child) => new Promise((resolve, reject) => {
156
+ child.once('error', reject);
157
+ child.once('close', (code) => {
158
+ if (code === 0) {
159
+ resolve();
160
+ }
161
+ else {
162
+ reject(new AppError(ErrorCode.INTERNAL, `${child.spawnfile} exited with code ${code ?? 'unknown'}`));
163
+ }
164
+ });
165
+ });
166
+ await Promise.all([
167
+ pipeline(pgDump.stdout, gpg.stdin),
168
+ waitForExit(pgDump),
169
+ waitForExit(gpg)
170
+ ]);
171
+ return;
172
+ }
173
+ const file = createWriteStream(destination);
174
+ const waitForExit = (child) => new Promise((resolve, reject) => {
175
+ child.once('error', reject);
176
+ child.once('close', (code) => {
177
+ if (code === 0) {
178
+ resolve();
179
+ }
180
+ else {
181
+ reject(new AppError(ErrorCode.INTERNAL, `${child.spawnfile} exited with code ${code ?? 'unknown'}`));
182
+ }
183
+ });
184
+ });
185
+ await Promise.all([pipeline(pgDump.stdout, file), waitForExit(pgDump)]);
186
+ }
187
+ const program = new Command();
188
+ program
189
+ .name('pgm')
190
+ .description('Postgram human CLI')
191
+ .option('--json', 'emit JSON output');
192
+ program
193
+ .command('store')
194
+ .alias('add')
195
+ .description('Store an entity')
196
+ .argument('[content]', 'entity content')
197
+ .option('--type <type>', 'entity type', 'memory')
198
+ .option('--visibility <visibility>', 'entity visibility', 'shared')
199
+ .option('--owner <owner>', 'entity owner or namespace')
200
+ .option('--status <status>', 'entity status')
201
+ .option('--tags <tags>', 'comma-separated tags')
202
+ .option('--source <source>', 'entity source')
203
+ .option('--metadata <json>', 'JSON metadata object')
204
+ .action(async (content, options, command) => {
205
+ await runWithClient(command, async (client, json) => {
206
+ const body = await client.storeEntity({
207
+ type: options.type,
208
+ content: await resolveStoreContent(content),
209
+ visibility: options.visibility,
210
+ owner: options.owner,
211
+ status: options.status,
212
+ tags: parseCommaList(options.tags),
213
+ source: options.source,
214
+ metadata: parseJsonObject(options.metadata)
215
+ });
216
+ return json
217
+ ? body
218
+ : formatStoredEntity({
219
+ id: body.entity.id,
220
+ type: body.entity.type,
221
+ content: body.entity.content,
222
+ status: body.entity.status,
223
+ visibility: body.entity.visibility,
224
+ owner: body.entity.owner,
225
+ tags: body.entity.tags
226
+ });
227
+ });
228
+ });
229
+ program
230
+ .command('search')
231
+ .description('Search stored entities')
232
+ .argument('query', 'search query')
233
+ .option('--type <type>', 'entity type')
234
+ .option('--tags <tags>', 'comma-separated tags')
235
+ .option('--visibility <visibility>', 'entity visibility filter')
236
+ .option('--owner <owner>', 'entity owner filter')
237
+ .option('--limit <limit>', 'result limit', '10')
238
+ .option('--threshold <threshold>', 'similarity threshold', '0.35')
239
+ .option('--recency-weight <recencyWeight>', 'recency weight', '0.1')
240
+ .action(async (query, options, command) => {
241
+ await runWithClient(command, async (client, json) => {
242
+ const body = await client.searchEntities({
243
+ query,
244
+ type: options.type,
245
+ tags: parseCommaList(options.tags),
246
+ visibility: options.visibility,
247
+ owner: options.owner,
248
+ limit: Number(options.limit),
249
+ threshold: Number(options.threshold),
250
+ recency_weight: Number(options.recencyWeight)
251
+ });
252
+ return json ? body : formatSearchResults(body.results);
253
+ });
254
+ });
255
+ program
256
+ .command('recall')
257
+ .description('Recall an entity by ID')
258
+ .argument('id', 'entity ID')
259
+ .option('--owner <owner>', 'entity owner filter')
260
+ .action(async (id, options, command) => {
261
+ await runWithClient(command, async (client, json) => {
262
+ const body = await client.recallEntity(id, {
263
+ owner: options.owner
264
+ });
265
+ return json ? body : formatStoredEntity(body.entity);
266
+ });
267
+ });
268
+ program
269
+ .command('list')
270
+ .description('List entities')
271
+ .option('--type <type>', 'filter by type')
272
+ .option('--status <status>', 'filter by status')
273
+ .option('--visibility <visibility>', 'filter by visibility')
274
+ .option('--owner <owner>', 'filter by owner')
275
+ .option('--tags <tags>', 'comma-separated tags')
276
+ .option('--limit <limit>', 'result limit', '50')
277
+ .option('--offset <offset>', 'result offset', '0')
278
+ .action(async (options, command) => {
279
+ await runWithClient(command, async (client, json) => {
280
+ const body = await client.listEntities({
281
+ type: options.type,
282
+ status: options.status,
283
+ visibility: options.visibility,
284
+ owner: options.owner,
285
+ tags: parseCommaList(options.tags),
286
+ limit: Number(options.limit),
287
+ offset: Number(options.offset)
288
+ });
289
+ if (json) {
290
+ return body;
291
+ }
292
+ if (body.items.length === 0) {
293
+ return ['No entities'];
294
+ }
295
+ const lines = body.items.flatMap((item) => {
296
+ const preview = item.content
297
+ ? item.content.length > 60
298
+ ? `${item.content.slice(0, 60)}...`
299
+ : item.content
300
+ : '-';
301
+ return [
302
+ `${item.type} ${shortId(item.id)} ${preview}`,
303
+ ` tags: ${item.tags.join(', ') || '-'} | owner=${item.owner ?? 'shared'} | ${item.visibility} | ${item.created_at.slice(0, 10)}`
304
+ ];
305
+ });
306
+ lines.push('');
307
+ lines.push(`${body.total} entities (showing ${body.offset + 1}-${body.offset + body.items.length})`);
308
+ return lines;
309
+ });
310
+ });
311
+ program
312
+ .command('update')
313
+ .description('Update an entity')
314
+ .argument('id', 'entity ID')
315
+ .option('--content <content>', 'updated content')
316
+ .option('--visibility <visibility>', 'updated visibility')
317
+ .option('--status <status>', 'updated status')
318
+ .option('--tags <tags>', 'comma-separated tags')
319
+ .option('--source <source>', 'updated source')
320
+ .option('--metadata <json>', 'JSON metadata object')
321
+ .option('--version <version>', 'expected version')
322
+ .option('--force', 'retry using the latest version on conflict')
323
+ .action(async (id, options, command) => {
324
+ await runWithClient(command, async (client, json) => {
325
+ const payload = {
326
+ content: options.content,
327
+ visibility: options.visibility,
328
+ status: options.status,
329
+ tags: parseCommaList(options.tags),
330
+ source: options.source,
331
+ metadata: parseJsonObject(options.metadata)
332
+ };
333
+ const updateOnce = async (version) => client.updateEntity(id, {
334
+ version,
335
+ ...payload
336
+ });
337
+ let body;
338
+ if (options.version !== undefined) {
339
+ body = await updateOnce(Number(options.version));
340
+ }
341
+ else if (options.force) {
342
+ const current = await client.recallEntity(id);
343
+ body = await updateOnce(current.entity.version);
344
+ }
345
+ else {
346
+ throw new Error('--version is required unless --force is set');
347
+ }
348
+ return json ? body : formatStoredEntity(body.entity);
349
+ });
350
+ });
351
+ program
352
+ .command('delete')
353
+ .description('Soft delete an entity')
354
+ .argument('id', 'entity ID')
355
+ .action(async (id, _options, command) => {
356
+ await runWithClient(command, async (client, json) => {
357
+ const body = await client.deleteEntity(id);
358
+ return json ? body : [`Deleted ${shortId(body.id)}`];
359
+ });
360
+ });
361
+ const taskCommand = program.command('task').description('Task commands');
362
+ taskCommand
363
+ .command('add')
364
+ .description('Create a task')
365
+ .argument('[content]', 'task content')
366
+ .option('--context <context>', 'GTD context')
367
+ .option('--status <status>', 'task status', 'inbox')
368
+ .option('--due <dueDate>', 'due date')
369
+ .option('--tags <tags>', 'comma-separated tags')
370
+ .option('--visibility <visibility>', 'task visibility', 'shared')
371
+ .option('--metadata <json>', 'JSON metadata object')
372
+ .action(async (content, options, command) => {
373
+ await runWithClient(command, async (client, json) => {
374
+ const taskContent = await resolveStoreContent(content);
375
+ const body = await client.createTask({
376
+ content: taskContent,
377
+ context: options.context,
378
+ status: options.status,
379
+ due_date: options.due,
380
+ tags: parseCommaList(options.tags),
381
+ visibility: options.visibility,
382
+ metadata: parseJsonObject(options.metadata)
383
+ });
384
+ return json
385
+ ? body
386
+ : formatStoredEntity({
387
+ id: body.entity.id,
388
+ type: body.entity.type,
389
+ content: body.entity.content,
390
+ status: body.entity.status,
391
+ visibility: body.entity.visibility,
392
+ owner: body.entity.owner,
393
+ tags: body.entity.tags
394
+ });
395
+ });
396
+ });
397
+ taskCommand
398
+ .command('list')
399
+ .description('List tasks')
400
+ .option('--status <status>', 'filter by status')
401
+ .option('--context <context>', 'filter by context')
402
+ .option('--limit <limit>', 'result limit', '50')
403
+ .option('--offset <offset>', 'result offset', '0')
404
+ .action(async (options, command) => {
405
+ await runWithClient(command, async (client, json) => {
406
+ const body = await client.listTasks({
407
+ status: options.status,
408
+ context: options.context,
409
+ limit: Number(options.limit),
410
+ offset: Number(options.offset)
411
+ });
412
+ return json ? body : formatTaskList(body.items);
413
+ });
414
+ });
415
+ taskCommand
416
+ .command('update')
417
+ .description('Update a task')
418
+ .argument('id', 'task ID')
419
+ .option('--content <content>', 'updated content')
420
+ .option('--context <context>', 'updated context')
421
+ .option('--status <status>', 'updated status')
422
+ .option('--due <dueDate>', 'updated due date')
423
+ .option('--tags <tags>', 'comma-separated tags')
424
+ .option('--visibility <visibility>', 'updated task visibility')
425
+ .option('--metadata <json>', 'JSON metadata object')
426
+ .option('--version <version>', 'expected version', '')
427
+ .action(async (id, options, command) => {
428
+ await runWithClient(command, async (client, json) => {
429
+ if (!options.version) {
430
+ throw new AppError(ErrorCode.VALIDATION, '--version is required');
431
+ }
432
+ const body = await client.updateTask(id, {
433
+ version: Number(options.version),
434
+ content: options.content,
435
+ context: options.context,
436
+ status: options.status,
437
+ due_date: options.due,
438
+ tags: parseCommaList(options.tags),
439
+ visibility: options.visibility,
440
+ metadata: parseJsonObject(options.metadata)
441
+ });
442
+ return json
443
+ ? body
444
+ : formatStoredEntity({
445
+ id: body.entity.id,
446
+ type: body.entity.type,
447
+ content: body.entity.content,
448
+ status: body.entity.status,
449
+ visibility: body.entity.visibility,
450
+ owner: body.entity.owner,
451
+ tags: body.entity.tags
452
+ });
453
+ });
454
+ });
455
+ taskCommand
456
+ .command('complete')
457
+ .description('Mark a task complete')
458
+ .argument('id', 'task ID')
459
+ .option('--version <version>', 'expected version')
460
+ .action(async (id, options, command) => {
461
+ await runWithClient(command, async (client, json) => {
462
+ if (options.version === undefined) {
463
+ throw new AppError(ErrorCode.VALIDATION, '--version is required');
464
+ }
465
+ const body = await client.completeTask(id, Number(options.version));
466
+ return json
467
+ ? body
468
+ : formatStoredEntity({
469
+ id: body.entity.id,
470
+ type: body.entity.type,
471
+ content: body.entity.content,
472
+ status: body.entity.status,
473
+ visibility: body.entity.visibility,
474
+ owner: body.entity.owner,
475
+ tags: body.entity.tags
476
+ });
477
+ });
478
+ });
479
+ program
480
+ .command('backup')
481
+ .description('Create a database backup')
482
+ .option('--output <path>', 'backup output path')
483
+ .option('--encrypt', 'encrypt the backup with GPG')
484
+ .action(async (options, command) => {
485
+ const json = isJsonMode(command);
486
+ try {
487
+ await runBackup(options.output, Boolean(options.encrypt));
488
+ if (json) {
489
+ printJson({ ok: true });
490
+ }
491
+ else {
492
+ printHuman(['Backup completed']);
493
+ }
494
+ }
495
+ catch (error) {
496
+ await handleCliFailure(error, json);
497
+ }
498
+ });
499
+ program
500
+ .command('sync')
501
+ .description('Sync a local directory of markdown files')
502
+ .argument('<dir>', 'directory path to sync')
503
+ .option('--repo <name>', 'repo identifier (defaults to directory name)')
504
+ .option('--dry-run', 'show what would change without syncing')
505
+ .option('--quiet', 'suppress output')
506
+ .action(async (dir, options, command) => {
507
+ const json = isJsonMode(command);
508
+ try {
509
+ const resolvedDir = path.resolve(dir);
510
+ const repoName = options.repo ?? path.basename(resolvedDir);
511
+ const files = [];
512
+ const SKIP_DIRS = new Set(['.git', 'node_modules', '.obsidian', '.trash']);
513
+ async function walk(dirPath, prefix) {
514
+ const entries = await readdir(dirPath, { withFileTypes: true });
515
+ for (const entry of entries) {
516
+ if (entry.isDirectory()) {
517
+ if (!SKIP_DIRS.has(entry.name) && !entry.name.startsWith('.')) {
518
+ await walk(path.join(dirPath, entry.name), prefix ? `${prefix}/${entry.name}` : entry.name);
519
+ }
520
+ }
521
+ else if (entry.isFile() && entry.name.endsWith('.md')) {
522
+ const fullPath = path.join(dirPath, entry.name);
523
+ const content = await fsReadFile(fullPath, 'utf8');
524
+ const sha = createHash('sha256').update(content).digest('hex');
525
+ const relativePath = prefix
526
+ ? `${prefix}/${entry.name}`
527
+ : entry.name;
528
+ files.push({ path: relativePath, sha, content });
529
+ }
530
+ }
531
+ }
532
+ await walk(resolvedDir, '');
533
+ const config = await resolvePgmConfig();
534
+ const client = createPgmClient(config);
535
+ if (options.dryRun) {
536
+ const status = await client.getSyncStatus(repoName);
537
+ const existingByPath = new Map(status.files.map((f) => [f.path, f]));
538
+ const incomingPaths = new Set(files.map((f) => f.path));
539
+ let newCount = 0;
540
+ let changedCount = 0;
541
+ let unchangedCount = 0;
542
+ for (const file of files) {
543
+ const existing = existingByPath.get(file.path);
544
+ if (!existing) {
545
+ newCount += 1;
546
+ }
547
+ else if (existing.sha !== file.sha) {
548
+ changedCount += 1;
549
+ }
550
+ else {
551
+ unchangedCount += 1;
552
+ }
553
+ }
554
+ let deletedCount = 0;
555
+ for (const [existingPath, entry] of existingByPath) {
556
+ if (!incomingPaths.has(existingPath) && entry.syncStatus !== 'stale') {
557
+ deletedCount += 1;
558
+ }
559
+ }
560
+ const result = {
561
+ created: newCount,
562
+ updated: changedCount,
563
+ unchanged: unchangedCount,
564
+ deleted: deletedCount
565
+ };
566
+ if (json) {
567
+ printJson(result);
568
+ }
569
+ else if (!options.quiet) {
570
+ printHuman([
571
+ `Dry run ${repoName}: ${result.created} to create, ${result.updated} to update, ${result.unchanged} unchanged, ${result.deleted} to delete`
572
+ ]);
573
+ }
574
+ return;
575
+ }
576
+ const result = await client.syncRepo({ repo: repoName, files });
577
+ if (json) {
578
+ printJson(result);
579
+ }
580
+ else if (!options.quiet) {
581
+ printHuman([
582
+ `Synced ${repoName}: ${result.created} created, ${result.updated} updated, ${result.unchanged} unchanged, ${result.deleted} deleted`
583
+ ]);
584
+ }
585
+ }
586
+ catch (error) {
587
+ await handleCliFailure(error, json);
588
+ }
589
+ });
590
+ program
591
+ .command('link')
592
+ .description('Create an edge between two entities')
593
+ .argument('<source-id>', 'source entity ID')
594
+ .argument('<target-id>', 'target entity ID')
595
+ .requiredOption('--relation <relation>', 'relationship type')
596
+ .option('--confidence <n>', 'confidence score 0-1', '1.0')
597
+ .action(async (sourceId, targetId, options, command) => {
598
+ await runWithClient(command, async (client, json) => {
599
+ const body = await client.createEdge({
600
+ source_id: sourceId,
601
+ target_id: targetId,
602
+ relation: options.relation,
603
+ confidence: Number(options.confidence)
604
+ });
605
+ if (json)
606
+ return body;
607
+ return [`Linked ${shortId(sourceId)} → ${shortId(targetId)} (${options.relation})`];
608
+ });
609
+ });
610
+ program
611
+ .command('unlink')
612
+ .description('Delete an edge')
613
+ .argument('<edge-id>', 'edge ID')
614
+ .action(async (edgeId, _options, command) => {
615
+ await runWithClient(command, async (client, json) => {
616
+ const body = await client.deleteEdge(edgeId);
617
+ return json ? body : [`Deleted edge ${shortId(edgeId)}`];
618
+ });
619
+ });
620
+ program
621
+ .command('expand')
622
+ .description('Show graph neighborhood of an entity')
623
+ .argument('<entity-id>', 'entity ID')
624
+ .option('--depth <n>', 'traversal depth (1-3)', '1')
625
+ .option('--relation <types>', 'comma-separated relation types')
626
+ .option('--owner <owner>', 'owner filter')
627
+ .action(async (entityId, options, command) => {
628
+ await runWithClient(command, async (client, json) => {
629
+ const relationTypes = parseCommaList(options.relation);
630
+ const body = await client.expandGraph(entityId, {
631
+ depth: Number(options.depth),
632
+ ...(relationTypes !== undefined ? { relationTypes } : {}),
633
+ ...(options.owner !== undefined ? { owner: options.owner } : {})
634
+ });
635
+ if (json)
636
+ return body;
637
+ const lines = [];
638
+ lines.push(`Graph for ${shortId(entityId)}:`);
639
+ lines.push(` ${body.entities.length} entities, ${body.edges.length} edges`);
640
+ for (const edge of body.edges) {
641
+ lines.push(` ${shortId(edge.source_id)} → ${shortId(edge.target_id)} (${edge.relation})`);
642
+ }
643
+ return lines;
644
+ });
645
+ });
646
+ await program.parseAsync(process.argv);
package/dist/shared.js ADDED
@@ -0,0 +1,106 @@
1
+ import { statSync } from 'node:fs';
2
+ import { readFile } from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { AppError, ErrorCode, toErrorResponse } from './errors.js';
6
+ export function isJsonMode(command) {
7
+ return Boolean(command.optsWithGlobals().json);
8
+ }
9
+ export function parseCommaList(value) {
10
+ if (value === undefined) {
11
+ return undefined;
12
+ }
13
+ const items = value
14
+ .split(',')
15
+ .map((item) => item.trim())
16
+ .filter(Boolean);
17
+ return items.length > 0 ? items : undefined;
18
+ }
19
+ export function parseJsonObject(value, fallback = {}) {
20
+ if (value === undefined) {
21
+ return fallback;
22
+ }
23
+ let parsed;
24
+ try {
25
+ parsed = JSON.parse(value);
26
+ }
27
+ catch (error) {
28
+ throw new AppError(ErrorCode.VALIDATION, `Invalid JSON: ${error instanceof Error ? error.message : 'parse failed'}`);
29
+ }
30
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
31
+ throw new AppError(ErrorCode.VALIDATION, 'Metadata must be a JSON object');
32
+ }
33
+ return parsed;
34
+ }
35
+ export async function readStdinText() {
36
+ if (process.stdin.isTTY) {
37
+ return '';
38
+ }
39
+ return await new Promise((resolve, reject) => {
40
+ let data = '';
41
+ process.stdin.setEncoding('utf8');
42
+ process.stdin.on('data', (chunk) => {
43
+ data += chunk;
44
+ });
45
+ process.stdin.on('end', () => resolve(data.trim()));
46
+ process.stdin.on('error', reject);
47
+ });
48
+ }
49
+ const INSECURE_PGMRC_MODE_MASK = 0o077;
50
+ function normalizeUrl(url) {
51
+ return url.replace(/\/+$/, '');
52
+ }
53
+ export async function resolvePgmConfig() {
54
+ const envUrl = process.env.PGM_API_URL;
55
+ const envKey = process.env.PGM_API_KEY;
56
+ if (envUrl && envKey) {
57
+ return {
58
+ apiUrl: normalizeUrl(envUrl),
59
+ apiKey: envKey
60
+ };
61
+ }
62
+ const rcPath = path.join(os.homedir(), '.pgmrc');
63
+ try {
64
+ const raw = await readFile(rcPath, 'utf8');
65
+ const fileStat = statSync(rcPath);
66
+ if ((fileStat.mode & INSECURE_PGMRC_MODE_MASK) !== 0) {
67
+ process.stderr.write("Warning: ~/.pgmrc is accessible by other users. Run 'chmod 600 ~/.pgmrc' to fix.\n");
68
+ }
69
+ const parsed = JSON.parse(raw);
70
+ if (!parsed.api_url || !parsed.api_key) {
71
+ throw new Error('missing api_url or api_key');
72
+ }
73
+ return {
74
+ apiUrl: normalizeUrl(parsed.api_url),
75
+ apiKey: parsed.api_key
76
+ };
77
+ }
78
+ catch (error) {
79
+ throw new AppError(ErrorCode.VALIDATION, 'PGM_API_URL and PGM_API_KEY must be set or ~/.pgmrc must exist', error instanceof Error ? { cause: error.message } : {});
80
+ }
81
+ }
82
+ export function shortId(id) {
83
+ return id.slice(0, 8);
84
+ }
85
+ export function printJson(value) {
86
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
87
+ }
88
+ export function printHuman(lines) {
89
+ process.stdout.write(`${lines.join('\n')}\n`);
90
+ }
91
+ export function formatAppError(error) {
92
+ const payload = toErrorResponse(error);
93
+ return [`${payload.error.code}: ${payload.error.message}`];
94
+ }
95
+ export async function handleCliFailure(error, json) {
96
+ const appError = error instanceof AppError
97
+ ? error
98
+ : new AppError(ErrorCode.INTERNAL, error instanceof Error ? error.message : 'Unexpected CLI failure');
99
+ if (json) {
100
+ printJson(toErrorResponse(appError));
101
+ }
102
+ else {
103
+ printHuman(formatAppError(appError));
104
+ }
105
+ process.exitCode = 1;
106
+ }
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@ivotoby/postgram-cli",
3
- "version": "1.0.5",
3
+ "version": "1.0.6",
4
4
  "description": "Postgram CLI — store, search, and manage entities from the command line",
5
5
  "type": "module",
6
6
  "bin": {
7
- "pgm": "dist/src/pgm.js"
7
+ "pgm": "dist/pgm.js"
8
8
  },
9
9
  "files": [
10
- "dist/src",
10
+ "dist",
11
11
  "README.md"
12
12
  ],
13
13
  "repository": {
@@ -32,6 +32,7 @@
32
32
  },
33
33
  "scripts": {
34
34
  "build": "tsc -p tsconfig.build.json",
35
+ "prepublishOnly": "npm run build",
35
36
  "test": "vitest run",
36
37
  "test:watch": "vitest",
37
38
  "lint": "eslint .",