@ivotoby/postgram-cli 1.0.5 → 1.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/dist/client.js +174 -0
- package/dist/errors.js +32 -0
- package/dist/pgm.js +646 -0
- package/dist/shared.js +106 -0
- package/package.json +4 -3
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
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Postgram CLI — store, search, and manage entities from the command line",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"pgm": "dist/
|
|
7
|
+
"pgm": "dist/pgm.js"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
|
-
"dist
|
|
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 .",
|