@vorlek/cli 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/README.md +33 -0
- package/dist/cli.js +76 -0
- package/dist/client.js +148 -0
- package/dist/commands/auth.js +157 -0
- package/dist/commands/campaign.js +108 -0
- package/dist/commands/catalog.js +53 -0
- package/dist/commands/connect.js +126 -0
- package/dist/commands/connection.js +45 -0
- package/dist/commands/contact.js +120 -0
- package/dist/commands/detail.js +12 -0
- package/dist/commands/email.js +67 -0
- package/dist/commands/operation.js +38 -0
- package/dist/commands/provider.js +16 -0
- package/dist/commands/status.js +65 -0
- package/dist/commands/table.js +10 -0
- package/dist/commands/template.js +82 -0
- package/dist/config.js +83 -0
- package/dist/deps.js +39 -0
- package/dist/idempotency.js +22 -0
- package/dist/run-context.js +79 -0
- package/package.json +49 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import pc from 'picocolors';
|
|
2
|
+
import { ApiError } from '../client.js';
|
|
3
|
+
import { ConfigMissingError, renderError, renderSuccess } from '../run-context.js';
|
|
4
|
+
function availableAudiencesFromError(err) {
|
|
5
|
+
if (!(err instanceof ApiError))
|
|
6
|
+
return [];
|
|
7
|
+
const detail = err.envelope.error.detail;
|
|
8
|
+
if (typeof detail !== 'object' || detail === null)
|
|
9
|
+
return [];
|
|
10
|
+
const audiences = detail.available_audiences;
|
|
11
|
+
if (!Array.isArray(audiences))
|
|
12
|
+
return [];
|
|
13
|
+
return audiences.filter((audience) => {
|
|
14
|
+
if (typeof audience !== 'object' || audience === null)
|
|
15
|
+
return false;
|
|
16
|
+
const candidate = audience;
|
|
17
|
+
return typeof candidate.id === 'string' && typeof candidate.name === 'string';
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
function connectionPayload(provider, opts, listId) {
|
|
21
|
+
return {
|
|
22
|
+
provider,
|
|
23
|
+
api_key: opts.apiKey ?? '',
|
|
24
|
+
...(provider === 'mailchimp' && listId ? { list_id: listId } : {}),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function renderConnectSuccess(deps, ctx, provider, conn) {
|
|
28
|
+
renderSuccess(deps, ctx, conn, () => `${pc.green('✓')} Connected ${pc.bold(provider)} ${conn.account_name ? `(${pc.dim(conn.account_name)}) ` : ''}— connection_id ${pc.dim(conn.connection_id)}`);
|
|
29
|
+
}
|
|
30
|
+
export async function runConnect(deps, ctx, provider, opts) {
|
|
31
|
+
try {
|
|
32
|
+
if (!opts.apiKey)
|
|
33
|
+
throw new Error('Missing --api-key flag.');
|
|
34
|
+
if (provider === 'klaviyo') {
|
|
35
|
+
if (opts.listId)
|
|
36
|
+
throw new Error('Klaviyo connections do not use --list-id.');
|
|
37
|
+
if (opts.apiKey.startsWith('pub_') && !ctx.json) {
|
|
38
|
+
deps.stderr.write(`${pc.yellow('warning')} Klaviyo public keys start with pub_; use a private key starting with pk_ for write operations.\n`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const cfg = await deps.loadConfig();
|
|
42
|
+
if (!cfg)
|
|
43
|
+
throw new ConfigMissingError();
|
|
44
|
+
const apiBase = ctx.apiBaseOverride ?? cfg.api_base;
|
|
45
|
+
const client = deps.createApiClient({ apiBase, apiKey: cfg.api_key });
|
|
46
|
+
try {
|
|
47
|
+
const conn = await client.createConnection(connectionPayload(provider, opts, opts.listId));
|
|
48
|
+
renderConnectSuccess(deps, ctx, provider, conn);
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
const audiences = availableAudiencesFromError(err);
|
|
52
|
+
if (provider !== 'mailchimp' || ctx.json || opts.listId || audiences.length === 0)
|
|
53
|
+
throw err;
|
|
54
|
+
const selected = await deps.prompts.select({
|
|
55
|
+
message: 'Select Mailchimp audience',
|
|
56
|
+
options: audiences.map((audience) => ({
|
|
57
|
+
value: audience.id,
|
|
58
|
+
label: audience.name,
|
|
59
|
+
hint: audience.id,
|
|
60
|
+
})),
|
|
61
|
+
});
|
|
62
|
+
if (deps.prompts.isCancel(selected))
|
|
63
|
+
throw new Error('Audience selection cancelled.');
|
|
64
|
+
const conn = await client.createConnection(connectionPayload(provider, opts, String(selected)));
|
|
65
|
+
renderConnectSuccess(deps, ctx, provider, conn);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
deps.exit(renderError(deps, ctx, err));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
export async function runList(deps, ctx) {
|
|
73
|
+
try {
|
|
74
|
+
const cfg = await deps.loadConfig();
|
|
75
|
+
if (!cfg)
|
|
76
|
+
throw new ConfigMissingError();
|
|
77
|
+
const apiBase = ctx.apiBaseOverride ?? cfg.api_base;
|
|
78
|
+
const client = deps.createApiClient({ apiBase, apiKey: cfg.api_key });
|
|
79
|
+
const rows = await client.listConnections();
|
|
80
|
+
renderSuccess(deps, ctx, rows, () => {
|
|
81
|
+
if (rows.length === 0)
|
|
82
|
+
return pc.dim('No connections yet. Try `vorlek connect sendgrid --api-key SG.xxx`.');
|
|
83
|
+
const lines = [
|
|
84
|
+
`${pc.bold('PROVIDER')} ${pc.bold('STATUS')} ${pc.bold('ACCOUNT')} ${pc.bold('LAST VALIDATED')}`,
|
|
85
|
+
];
|
|
86
|
+
for (const r of rows) {
|
|
87
|
+
lines.push(`${r.provider.padEnd(9)} ${r.status.padEnd(7)} ${(r.account_name ?? '-').padEnd(8)} ${r.last_validated_at ?? '-'}`);
|
|
88
|
+
}
|
|
89
|
+
return lines.join('\n');
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
deps.exit(renderError(deps, ctx, err));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export async function runRemove(deps, ctx, provider) {
|
|
97
|
+
try {
|
|
98
|
+
if (!provider)
|
|
99
|
+
throw new Error('Missing <provider> argument: `vorlek connect remove <provider>`.');
|
|
100
|
+
const cfg = await deps.loadConfig();
|
|
101
|
+
if (!cfg)
|
|
102
|
+
throw new ConfigMissingError();
|
|
103
|
+
const apiBase = ctx.apiBaseOverride ?? cfg.api_base;
|
|
104
|
+
const client = deps.createApiClient({ apiBase, apiKey: cfg.api_key });
|
|
105
|
+
const result = await client.removeConnection(provider);
|
|
106
|
+
renderSuccess(deps, ctx, result, () => `${pc.green('✓')} Removed ${pc.bold(provider)} connection.`);
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
deps.exit(renderError(deps, ctx, err));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
export function registerConnectCommands(cli, deps, ctx) {
|
|
113
|
+
cli
|
|
114
|
+
.command('connect <action> [target]', 'Connect: <provider> --api-key <k> | list | remove <provider>')
|
|
115
|
+
.option('--api-key <key>', 'Provider API key (required for connect <provider>)')
|
|
116
|
+
.option('--list-id <id>', 'Mailchimp audience/list id')
|
|
117
|
+
.action(async (action, target, opts) => {
|
|
118
|
+
if (action === 'list')
|
|
119
|
+
return runList(deps, ctx);
|
|
120
|
+
if (action === 'remove')
|
|
121
|
+
return runRemove(deps, ctx, target);
|
|
122
|
+
// Otherwise treat `action` as the provider name → create.
|
|
123
|
+
return runConnect(deps, ctx, action, opts);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
//# sourceMappingURL=connect.js.map
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import pc from 'picocolors';
|
|
2
|
+
import { ConfigMissingError, renderError, renderSuccess } from '../run-context.js';
|
|
3
|
+
export async function runCleanupTestProfiles(deps, ctx, opts) {
|
|
4
|
+
try {
|
|
5
|
+
if (!opts.provider)
|
|
6
|
+
throw new Error('Missing --provider flag.');
|
|
7
|
+
if (opts.provider !== 'klaviyo') {
|
|
8
|
+
throw new Error('cleanup-test-profiles currently supports only --provider klaviyo.');
|
|
9
|
+
}
|
|
10
|
+
if (!opts.pattern) {
|
|
11
|
+
throw new Error('Cleanup requires --pattern. Pass an exact email or bounded glob (e.g., dogfood*@test.vorlek.ci) to scope the suppression.');
|
|
12
|
+
}
|
|
13
|
+
const pattern = opts.pattern;
|
|
14
|
+
const cfg = await deps.loadConfig();
|
|
15
|
+
if (!cfg)
|
|
16
|
+
throw new ConfigMissingError();
|
|
17
|
+
const apiBase = ctx.apiBaseOverride ?? cfg.api_base;
|
|
18
|
+
const client = deps.createApiClient({ apiBase, apiKey: cfg.api_key });
|
|
19
|
+
if (!client.cleanupTestProfiles) {
|
|
20
|
+
throw new Error('Configured API client does not support cleanup-test-profiles.');
|
|
21
|
+
}
|
|
22
|
+
const result = await client.cleanupTestProfiles({ provider: opts.provider, pattern });
|
|
23
|
+
renderSuccess(deps, ctx, result, () => {
|
|
24
|
+
if (!result.job_id) {
|
|
25
|
+
return `${pc.yellow('!')} No Klaviyo profiles matched ${pc.bold(pattern)}.`;
|
|
26
|
+
}
|
|
27
|
+
return `${pc.green('✓')} Cleanup queued for ${pc.bold(String(result.suppressed_profiles))} Klaviyo profiles — job_id ${pc.dim(result.job_id)}`;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
deps.exit(renderError(deps, ctx, err));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export function registerConnectionCommands(cli, deps, ctx) {
|
|
35
|
+
cli
|
|
36
|
+
.command('connection <action>', 'Connection maintenance commands (internal: cleanup dogfood test profiles)')
|
|
37
|
+
.option('--provider <provider>', 'Provider to clean up; currently only klaviyo')
|
|
38
|
+
.option('--pattern <pattern>', 'Klaviyo cleanup email pattern (required)')
|
|
39
|
+
.action(async (action, opts) => {
|
|
40
|
+
if (action === 'cleanup-test-profiles')
|
|
41
|
+
return runCleanupTestProfiles(deps, ctx, opts);
|
|
42
|
+
deps.exit(renderError(deps, ctx, new Error(`Unknown connection action: ${action}`)));
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=connection.js.map
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import pc from 'picocolors';
|
|
2
|
+
import { ConfigMissingError, renderError, renderSuccess } from '../run-context.js';
|
|
3
|
+
import { parseDetail } from './detail.js';
|
|
4
|
+
import { resolveProvider } from './provider.js';
|
|
5
|
+
export async function runUpsertContact(deps, ctx, opts) {
|
|
6
|
+
try {
|
|
7
|
+
if (!opts.email)
|
|
8
|
+
throw new Error('Missing --email flag.');
|
|
9
|
+
const cfg = await deps.loadConfig();
|
|
10
|
+
if (!cfg)
|
|
11
|
+
throw new ConfigMissingError();
|
|
12
|
+
const apiBase = ctx.apiBaseOverride ?? cfg.api_base;
|
|
13
|
+
const client = deps.createApiClient({ apiBase, apiKey: cfg.api_key });
|
|
14
|
+
let provider = opts.provider;
|
|
15
|
+
if (!provider) {
|
|
16
|
+
const conns = await client.listConnections();
|
|
17
|
+
if (conns.length === 0) {
|
|
18
|
+
throw new Error('No connections found. Run `vorlek connect <provider> --api-key ...` first.');
|
|
19
|
+
}
|
|
20
|
+
if (conns.length > 1) {
|
|
21
|
+
throw new Error(`Multiple connections (${conns.map((c) => c.provider).join(', ')}). Pass --provider to disambiguate.`);
|
|
22
|
+
}
|
|
23
|
+
provider = conns[0]?.provider;
|
|
24
|
+
}
|
|
25
|
+
let properties;
|
|
26
|
+
if (opts.properties) {
|
|
27
|
+
try {
|
|
28
|
+
const parsed = JSON.parse(opts.properties);
|
|
29
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
30
|
+
throw new Error('--properties must be a JSON object.');
|
|
31
|
+
}
|
|
32
|
+
properties = parsed;
|
|
33
|
+
}
|
|
34
|
+
catch (parseErr) {
|
|
35
|
+
const msg = parseErr instanceof Error ? parseErr.message : String(parseErr);
|
|
36
|
+
throw new Error(`--properties: ${msg}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const detail = parseDetail(opts.detail);
|
|
40
|
+
const result = await client.upsertContact({
|
|
41
|
+
provider,
|
|
42
|
+
email: opts.email,
|
|
43
|
+
first_name: opts.firstName,
|
|
44
|
+
last_name: opts.lastName,
|
|
45
|
+
// Defensive String() coercion — cac may have parsed a numeric-looking
|
|
46
|
+
// phone (e.g., "+15551234567") as a JS Number; the server requires string.
|
|
47
|
+
phone: opts.phone === undefined ? undefined : String(opts.phone),
|
|
48
|
+
properties,
|
|
49
|
+
...(detail ? { detail } : {}),
|
|
50
|
+
...(opts.idempotencyKey ? { idempotencyKey: opts.idempotencyKey } : {}),
|
|
51
|
+
});
|
|
52
|
+
renderSuccess(deps, ctx, result, () => {
|
|
53
|
+
const fieldsHint = result.fields_auto_created.length > 0
|
|
54
|
+
? ` ${pc.dim(`(auto-created fields: ${result.fields_auto_created.join(', ')})`)}`
|
|
55
|
+
: '';
|
|
56
|
+
return `${pc.green('✓')} Upserted contact ${pc.bold(result.contact_id)} (${result.action}) via ${pc.bold(result.provider)}.${fieldsHint}`;
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
deps.exit(renderError(deps, ctx, err));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export async function runGetContact(deps, ctx, opts) {
|
|
64
|
+
try {
|
|
65
|
+
if (!opts.email)
|
|
66
|
+
throw new Error('Missing --email flag.');
|
|
67
|
+
const cfg = await deps.loadConfig();
|
|
68
|
+
if (!cfg)
|
|
69
|
+
throw new ConfigMissingError();
|
|
70
|
+
const apiBase = ctx.apiBaseOverride ?? cfg.api_base;
|
|
71
|
+
const client = deps.createApiClient({ apiBase, apiKey: cfg.api_key });
|
|
72
|
+
if (!client.getContact)
|
|
73
|
+
throw new Error('Configured API client does not support get_contact.');
|
|
74
|
+
const provider = await resolveProvider(client, opts.provider);
|
|
75
|
+
const detail = parseDetail(opts.detail);
|
|
76
|
+
const result = await client.getContact({
|
|
77
|
+
provider,
|
|
78
|
+
email: opts.email,
|
|
79
|
+
...(detail ? { detail } : {}),
|
|
80
|
+
...(opts.idempotencyKey ? { idempotencyKey: opts.idempotencyKey } : {}),
|
|
81
|
+
});
|
|
82
|
+
renderSuccess(deps, ctx, result, () => {
|
|
83
|
+
if (!result.found) {
|
|
84
|
+
return `${pc.yellow('!')} No ${pc.bold(result.provider)} contact found for ${pc.bold(result.email)}.`;
|
|
85
|
+
}
|
|
86
|
+
const contactId = contactIdFromResult(result.contact);
|
|
87
|
+
return `${pc.green('✓')} Found ${pc.bold(result.provider)} contact ${pc.bold(contactId ?? result.email)}.`;
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
deps.exit(renderError(deps, ctx, err));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
export function registerContactCommands(cli, deps, ctx) {
|
|
95
|
+
cli
|
|
96
|
+
.command('contact <action>', 'Contact operations: upsert, get')
|
|
97
|
+
.option('--email <email>', 'Contact email (required for upsert and get)')
|
|
98
|
+
.option('--first-name <name>', 'First name')
|
|
99
|
+
.option('--last-name <name>', 'Last name')
|
|
100
|
+
.option('--phone <phone>', 'Phone number')
|
|
101
|
+
.option('--properties <json>', 'Custom properties as JSON, e.g. \'{"plan":"gold","tier":"silver"}\'')
|
|
102
|
+
.option('--provider <name>', 'Provider override (defaults to auto-detect)')
|
|
103
|
+
.option('--detail <level>', 'Response detail: minimal, standard, full')
|
|
104
|
+
.option('--idempotency-key <key>', 'Override the auto-generated idempotency key')
|
|
105
|
+
.action(async (action, opts) => {
|
|
106
|
+
if (action === 'upsert')
|
|
107
|
+
return runUpsertContact(deps, ctx, opts);
|
|
108
|
+
if (action === 'get')
|
|
109
|
+
return runGetContact(deps, ctx, opts);
|
|
110
|
+
deps.stderr.write(`${pc.red('error')} Unknown contact action: ${action}. Try: upsert, get\n`);
|
|
111
|
+
deps.exit(1);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
function contactIdFromResult(contact) {
|
|
115
|
+
if (typeof contact !== 'object' || contact === null)
|
|
116
|
+
return null;
|
|
117
|
+
const contactId = contact.contact_id;
|
|
118
|
+
return typeof contactId === 'string' ? contactId : null;
|
|
119
|
+
}
|
|
120
|
+
//# sourceMappingURL=contact.js.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const DETAIL_LEVELS = ['minimal', 'standard', 'full'];
|
|
2
|
+
export function parseDetail(raw) {
|
|
3
|
+
if (raw === undefined)
|
|
4
|
+
return undefined;
|
|
5
|
+
if (isDetailLevel(raw))
|
|
6
|
+
return raw;
|
|
7
|
+
throw new Error('--detail must be one of: minimal, standard, full.');
|
|
8
|
+
}
|
|
9
|
+
function isDetailLevel(value) {
|
|
10
|
+
return DETAIL_LEVELS.some((level) => level === value);
|
|
11
|
+
}
|
|
12
|
+
//# sourceMappingURL=detail.js.map
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import pc from 'picocolors';
|
|
2
|
+
import { ConfigMissingError, renderError, renderSuccess } from '../run-context.js';
|
|
3
|
+
import { parseDetail } from './detail.js';
|
|
4
|
+
import { resolveProvider } from './provider.js';
|
|
5
|
+
function parseVariables(raw) {
|
|
6
|
+
if (!raw)
|
|
7
|
+
return undefined;
|
|
8
|
+
const parsed = JSON.parse(raw);
|
|
9
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
10
|
+
throw new Error('--variables must be a JSON object.');
|
|
11
|
+
}
|
|
12
|
+
return parsed;
|
|
13
|
+
}
|
|
14
|
+
export async function runEmailSend(deps, ctx, opts) {
|
|
15
|
+
try {
|
|
16
|
+
if (!opts.to)
|
|
17
|
+
throw new Error('Missing --to flag.');
|
|
18
|
+
if (!opts.subject)
|
|
19
|
+
throw new Error('Missing --subject flag.');
|
|
20
|
+
const cfg = await deps.loadConfig();
|
|
21
|
+
if (!cfg)
|
|
22
|
+
throw new ConfigMissingError();
|
|
23
|
+
const apiBase = ctx.apiBaseOverride ?? cfg.api_base;
|
|
24
|
+
const client = deps.createApiClient({ apiBase, apiKey: cfg.api_key });
|
|
25
|
+
if (!client.sendTransactional)
|
|
26
|
+
throw new Error('Configured API client does not support send_transactional.');
|
|
27
|
+
const provider = await resolveProvider(client, opts.provider);
|
|
28
|
+
const detail = parseDetail(opts.detail);
|
|
29
|
+
const result = await client.sendTransactional({
|
|
30
|
+
provider,
|
|
31
|
+
to: opts.to,
|
|
32
|
+
from: opts.from,
|
|
33
|
+
subject: opts.subject,
|
|
34
|
+
template_id: opts.templateId,
|
|
35
|
+
html: opts.html,
|
|
36
|
+
text: opts.text,
|
|
37
|
+
variables: parseVariables(opts.variables),
|
|
38
|
+
...(detail ? { detail } : {}),
|
|
39
|
+
...(opts.idempotencyKey ? { idempotencyKey: opts.idempotencyKey } : {}),
|
|
40
|
+
});
|
|
41
|
+
renderSuccess(deps, ctx, result, () => `${pc.green('✓')} Sent email ${pc.bold(result.message_id)} via ${pc.bold(result.provider)}.`);
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
deps.exit(renderError(deps, ctx, err));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export function registerEmailCommands(cli, deps, ctx) {
|
|
48
|
+
cli
|
|
49
|
+
.command('email <action>', 'Email operations: send')
|
|
50
|
+
.option('--to <email>', 'Recipient email')
|
|
51
|
+
.option('--from <email>', 'Sender email')
|
|
52
|
+
.option('--subject <subject>', 'Email subject')
|
|
53
|
+
.option('--template-id <id>', 'Provider template id')
|
|
54
|
+
.option('--html <html>', 'HTML body')
|
|
55
|
+
.option('--text <text>', 'Text body')
|
|
56
|
+
.option('--variables <json>', 'Template variables as JSON')
|
|
57
|
+
.option('--provider <name>', 'Provider override (defaults to auto-detect)')
|
|
58
|
+
.option('--detail <level>', 'Response detail: minimal, standard, full')
|
|
59
|
+
.option('--idempotency-key <key>', 'Override the auto-generated idempotency key')
|
|
60
|
+
.action(async (action, opts) => {
|
|
61
|
+
if (action === 'send')
|
|
62
|
+
return runEmailSend(deps, ctx, opts);
|
|
63
|
+
deps.stderr.write(`${pc.red('error')} Unknown email action: ${action}. Try: send\n`);
|
|
64
|
+
deps.exit(1);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
//# sourceMappingURL=email.js.map
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import pc from 'picocolors';
|
|
2
|
+
import { ConfigMissingError, renderError, renderSuccess } from '../run-context.js';
|
|
3
|
+
export async function runOperationGet(deps, ctx, opts) {
|
|
4
|
+
try {
|
|
5
|
+
if (!opts.requestId)
|
|
6
|
+
throw new Error('Missing --request-id flag.');
|
|
7
|
+
const cfg = await deps.loadConfig();
|
|
8
|
+
if (!cfg)
|
|
9
|
+
throw new ConfigMissingError();
|
|
10
|
+
const apiBase = ctx.apiBaseOverride ?? cfg.api_base;
|
|
11
|
+
const client = deps.createApiClient({ apiBase, apiKey: cfg.api_key });
|
|
12
|
+
if (!client.getOperation)
|
|
13
|
+
throw new Error('Configured API client does not support operation lookup.');
|
|
14
|
+
const result = await client.getOperation(opts.requestId);
|
|
15
|
+
renderSuccess(deps, ctx, result, () => renderOperation(result));
|
|
16
|
+
}
|
|
17
|
+
catch (err) {
|
|
18
|
+
deps.exit(renderError(deps, ctx, err));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function registerOperationCommands(cli, deps, ctx) {
|
|
22
|
+
cli
|
|
23
|
+
.command('operation <action> [requestId]', 'Operation operations: get')
|
|
24
|
+
.option('--request-id <id>', 'Operation request id returned as meta.request_id')
|
|
25
|
+
.action(async (action, requestId, opts) => {
|
|
26
|
+
if (action === 'get') {
|
|
27
|
+
return runOperationGet(deps, ctx, { ...opts, requestId: opts.requestId ?? requestId });
|
|
28
|
+
}
|
|
29
|
+
deps.stderr.write(`${pc.red('error')} Unknown operation action: ${action}. Try: get\n`);
|
|
30
|
+
deps.exit(1);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
function renderOperation(operation) {
|
|
34
|
+
const provider = operation.provider ?? '-';
|
|
35
|
+
const error = operation.error_code ? `, error ${operation.error_code}` : '';
|
|
36
|
+
return `${pc.green('✓')} Operation ${pc.bold(operation.request_id)} ${operation.status} ${operation.tool} via ${provider} (HTTP ${operation.http_status}${error})`;
|
|
37
|
+
}
|
|
38
|
+
//# sourceMappingURL=operation.js.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export async function resolveProvider(client, provider) {
|
|
2
|
+
if (provider)
|
|
3
|
+
return provider;
|
|
4
|
+
const conns = await client.listConnections();
|
|
5
|
+
if (conns.length === 0) {
|
|
6
|
+
throw new Error('No connections found. Run `vorlek connect <provider> --api-key ...` first.');
|
|
7
|
+
}
|
|
8
|
+
if (conns.length > 1) {
|
|
9
|
+
throw new Error(`Multiple connections (${conns.map((c) => c.provider).join(', ')}). Pass --provider to disambiguate.`);
|
|
10
|
+
}
|
|
11
|
+
const only = conns[0]?.provider;
|
|
12
|
+
if (!only)
|
|
13
|
+
throw new Error('No provider found for the only connection.');
|
|
14
|
+
return only;
|
|
15
|
+
}
|
|
16
|
+
//# sourceMappingURL=provider.js.map
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import pc from 'picocolors';
|
|
2
|
+
import { ConfigMissingError, renderError, renderSuccess } from '../run-context.js';
|
|
3
|
+
/**
|
|
4
|
+
* Phase 1.5.9 — `vorlek status`. Composite "am I set up + how am I doing?"
|
|
5
|
+
* One round-trip per resource: identity, connections, usage. Renders a
|
|
6
|
+
* single block in human mode; in --json emits all three under one envelope.
|
|
7
|
+
*/
|
|
8
|
+
export function registerStatusCommand(cli, deps, ctx) {
|
|
9
|
+
cli
|
|
10
|
+
.command('status', 'Show account, connections, and current-period usage')
|
|
11
|
+
.option('--check', 'Freshly validate each connected provider')
|
|
12
|
+
.action(async (opts) => {
|
|
13
|
+
try {
|
|
14
|
+
const cfg = await deps.loadConfig();
|
|
15
|
+
if (!cfg)
|
|
16
|
+
throw new ConfigMissingError();
|
|
17
|
+
const apiBase = ctx.apiBaseOverride ?? cfg.api_base;
|
|
18
|
+
const client = deps.createApiClient({ apiBase, apiKey: cfg.api_key });
|
|
19
|
+
const [identity, connections, usage] = await Promise.all([
|
|
20
|
+
client.getAccount(),
|
|
21
|
+
client.listConnections(),
|
|
22
|
+
client.getUsage(),
|
|
23
|
+
]);
|
|
24
|
+
const checks = opts.check
|
|
25
|
+
? await Promise.all(connections.map((connection) => {
|
|
26
|
+
if (!client.getConnectionStatus) {
|
|
27
|
+
throw new Error('Configured API client does not support get_connection_status.');
|
|
28
|
+
}
|
|
29
|
+
return client.getConnectionStatus({ provider: connection.provider });
|
|
30
|
+
}))
|
|
31
|
+
: undefined;
|
|
32
|
+
const checksByProvider = new Map(checks?.map((check) => [check.provider, check]) ?? []);
|
|
33
|
+
renderSuccess(deps, ctx, { account: identity, connections, usage, checks }, () => {
|
|
34
|
+
const lines = [];
|
|
35
|
+
lines.push(pc.bold('account'));
|
|
36
|
+
lines.push(` ${identity.email} ${pc.dim(`(${identity.account_id})`)}`);
|
|
37
|
+
lines.push(` plan: ${identity.plan}`);
|
|
38
|
+
lines.push('');
|
|
39
|
+
lines.push(pc.bold('connections'));
|
|
40
|
+
if (connections.length === 0) {
|
|
41
|
+
lines.push(pc.dim(' (none)'));
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
for (const c of connections) {
|
|
45
|
+
const name = c.account_name ? ` — ${pc.dim(c.account_name)}` : '';
|
|
46
|
+
const fresh = checksByProvider.get(c.provider);
|
|
47
|
+
const checkText = fresh ? `, checked: ${fresh.status}` : '';
|
|
48
|
+
lines.push(` ${pc.green('●')} ${c.provider} (${c.status}${checkText})${name}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
lines.push('');
|
|
52
|
+
lines.push(pc.bold('usage'));
|
|
53
|
+
lines.push(` ${usage.quota.used} / ${usage.quota.limit} this period ${pc.dim(`(resets ${usage.quota.resets_at})`)}`);
|
|
54
|
+
if (usage.recent_operations.errors > 0) {
|
|
55
|
+
lines.push(` ${pc.yellow(String(usage.recent_operations.errors))} errors of ${usage.recent_operations.total} total`);
|
|
56
|
+
}
|
|
57
|
+
return lines.join('\n');
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
deps.exit(renderError(deps, ctx, err));
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
//# sourceMappingURL=status.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export function renderTable(headers, rows) {
|
|
2
|
+
const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => row[index]?.length ?? 0)));
|
|
3
|
+
const format = (cells) => cells.map((cell, index) => cell.padEnd(widths[index] ?? cell.length)).join(' ');
|
|
4
|
+
return [
|
|
5
|
+
format(headers),
|
|
6
|
+
format(widths.map((width) => '-'.repeat(width))),
|
|
7
|
+
...rows.map(format),
|
|
8
|
+
].join('\n');
|
|
9
|
+
}
|
|
10
|
+
//# sourceMappingURL=table.js.map
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import pc from 'picocolors';
|
|
2
|
+
import { ConfigMissingError, renderError, renderSuccess } from '../run-context.js';
|
|
3
|
+
import { parseDetail } from './detail.js';
|
|
4
|
+
import { resolveProvider } from './provider.js';
|
|
5
|
+
import { renderTable } from './table.js';
|
|
6
|
+
const ALL_PAGE_CAP = 100;
|
|
7
|
+
export async function runTemplateList(deps, ctx, opts) {
|
|
8
|
+
try {
|
|
9
|
+
const cfg = await deps.loadConfig();
|
|
10
|
+
if (!cfg)
|
|
11
|
+
throw new ConfigMissingError();
|
|
12
|
+
const apiBase = ctx.apiBaseOverride ?? cfg.api_base;
|
|
13
|
+
const client = deps.createApiClient({ apiBase, apiKey: cfg.api_key });
|
|
14
|
+
if (!client.template?.list)
|
|
15
|
+
throw new Error('Configured API client does not support list_templates.');
|
|
16
|
+
const provider = await resolveProvider(client, opts.provider);
|
|
17
|
+
const limit = parseLimit(opts.limit);
|
|
18
|
+
const detail = parseDetail(opts.detail);
|
|
19
|
+
const templates = [];
|
|
20
|
+
let cursor = opts.cursor ?? null;
|
|
21
|
+
let nextCursor = null;
|
|
22
|
+
for (let page = 0;; page++) {
|
|
23
|
+
if (page >= ALL_PAGE_CAP) {
|
|
24
|
+
throw new Error('--all reached the 100-page safety cap. Use --cursor to continue from the last seen page.');
|
|
25
|
+
}
|
|
26
|
+
const result = await client.template.list({
|
|
27
|
+
provider,
|
|
28
|
+
...(limit ? { limit } : {}),
|
|
29
|
+
...(cursor ? { cursor } : {}),
|
|
30
|
+
...(detail ? { detail } : {}),
|
|
31
|
+
...(opts.idempotencyKey ? { idempotencyKey: opts.idempotencyKey } : {}),
|
|
32
|
+
});
|
|
33
|
+
templates.push(...result.templates);
|
|
34
|
+
nextCursor = result.next_cursor;
|
|
35
|
+
if (!opts.all || !nextCursor)
|
|
36
|
+
break;
|
|
37
|
+
cursor = nextCursor;
|
|
38
|
+
}
|
|
39
|
+
renderSuccess(deps, ctx, { provider, templates, next_cursor: nextCursor }, () => renderTemplates(provider, templates, nextCursor));
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
deps.exit(renderError(deps, ctx, err));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export function registerTemplateCommands(cli, deps, ctx) {
|
|
46
|
+
cli
|
|
47
|
+
.command('template <action>', 'Template operations: list')
|
|
48
|
+
.option('--provider <name>', 'Provider override (defaults to auto-detect)')
|
|
49
|
+
.option('--limit <n>', 'Page size, 1-100')
|
|
50
|
+
.option('--cursor <cursor>', 'Pagination cursor returned by the previous page')
|
|
51
|
+
.option('--all', 'Follow next_cursor until exhausted (max 100 pages)')
|
|
52
|
+
.option('--detail <level>', 'Response detail: minimal, standard, full')
|
|
53
|
+
.option('--idempotency-key <key>', 'Override the auto-generated idempotency key')
|
|
54
|
+
.action(async (action, opts) => {
|
|
55
|
+
if (action === 'list')
|
|
56
|
+
return runTemplateList(deps, ctx, opts);
|
|
57
|
+
deps.stderr.write(`${pc.red('error')} Unknown template action: ${action}. Try: list\n`);
|
|
58
|
+
deps.exit(1);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
function parseLimit(raw) {
|
|
62
|
+
if (raw === undefined)
|
|
63
|
+
return undefined;
|
|
64
|
+
const parsed = typeof raw === 'number' ? raw : Number.parseInt(raw, 10);
|
|
65
|
+
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 100) {
|
|
66
|
+
throw new Error('--limit must be an integer between 1 and 100.');
|
|
67
|
+
}
|
|
68
|
+
return parsed;
|
|
69
|
+
}
|
|
70
|
+
function renderTemplates(provider, templates, nextCursor) {
|
|
71
|
+
if (templates.length === 0)
|
|
72
|
+
return pc.dim(`No templates found for ${provider}.`);
|
|
73
|
+
const table = renderTable(['id', 'name', 'subject', 'updated_at'], templates.map((template) => [
|
|
74
|
+
template.id,
|
|
75
|
+
template.name,
|
|
76
|
+
template.subject ?? '-',
|
|
77
|
+
template.updated_at,
|
|
78
|
+
]));
|
|
79
|
+
const cursorHint = nextCursor ? `\n${pc.dim(`next_cursor: ${nextCursor}`)}` : '';
|
|
80
|
+
return `${pc.green('✓')} ${pc.bold(provider)} templates\n${table}${cursorHint}`;
|
|
81
|
+
}
|
|
82
|
+
//# sourceMappingURL=template.js.map
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, stat, unlink, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
/**
|
|
6
|
+
* Phase 1.5.2 — local config file at `~/.vorlek/config.json` with 0600
|
|
7
|
+
* permissions (owner read/write only). Stores the live API key plus
|
|
8
|
+
* minimal account context so commands like `whoami` and `status` don't
|
|
9
|
+
* have to round-trip the server unless they want fresh data.
|
|
10
|
+
*
|
|
11
|
+
* Test_api_key is intentionally NOT persisted — `signup` prints it once
|
|
12
|
+
* for the user to copy somewhere if they want it. Persisting both keys
|
|
13
|
+
* would make a single config-leak more dangerous than necessary.
|
|
14
|
+
*/
|
|
15
|
+
export const CONFIG_DIR = join(homedir(), '.vorlek');
|
|
16
|
+
export const CONFIG_PATH = join(CONFIG_DIR, 'config.json');
|
|
17
|
+
export const CONFIG_FILE_MODE = 0o600;
|
|
18
|
+
export const CONFIG_DIR_MODE = 0o700;
|
|
19
|
+
export const ConfigSchema = z
|
|
20
|
+
.object({
|
|
21
|
+
api_base: z.string().url(),
|
|
22
|
+
api_key: z.string().min(1),
|
|
23
|
+
account_id: z.string().min(1),
|
|
24
|
+
email: z.string().email(),
|
|
25
|
+
created_at: z.string(),
|
|
26
|
+
})
|
|
27
|
+
.strict();
|
|
28
|
+
/** Returns the parsed config or `null` if no config file exists yet. */
|
|
29
|
+
export async function loadConfig(path = CONFIG_PATH) {
|
|
30
|
+
let raw;
|
|
31
|
+
try {
|
|
32
|
+
raw = await readFile(path, 'utf8');
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
const code = err.code;
|
|
36
|
+
if (code === 'ENOENT')
|
|
37
|
+
return null;
|
|
38
|
+
throw err;
|
|
39
|
+
}
|
|
40
|
+
let parsed;
|
|
41
|
+
try {
|
|
42
|
+
parsed = JSON.parse(raw);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
throw new Error(`Config file at ${path} is not valid JSON.`);
|
|
46
|
+
}
|
|
47
|
+
return ConfigSchema.parse(parsed);
|
|
48
|
+
}
|
|
49
|
+
export async function saveConfig(config, path = CONFIG_PATH) {
|
|
50
|
+
// Validate before writing so a bug in the caller can't poison the file.
|
|
51
|
+
const validated = ConfigSchema.parse(config);
|
|
52
|
+
await mkdir(dirname(path), { recursive: true, mode: CONFIG_DIR_MODE });
|
|
53
|
+
// writeFile's `mode` only applies when *creating* the file. If a previous
|
|
54
|
+
// run left the file at 0644 (e.g., from a buggy older CLI version), the
|
|
55
|
+
// mode would persist. Explicit chmod after write fixes both new + existing.
|
|
56
|
+
await writeFile(path, JSON.stringify(validated, null, 2), { mode: CONFIG_FILE_MODE });
|
|
57
|
+
await chmod(path, CONFIG_FILE_MODE);
|
|
58
|
+
}
|
|
59
|
+
export async function deleteConfig(path = CONFIG_PATH) {
|
|
60
|
+
try {
|
|
61
|
+
await unlink(path);
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
const code = err.code;
|
|
66
|
+
if (code === 'ENOENT')
|
|
67
|
+
return false;
|
|
68
|
+
throw err;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Verify the current file mode is 0600. Used by tests + paranoid status check. */
|
|
72
|
+
export async function configFileMode(path = CONFIG_PATH) {
|
|
73
|
+
try {
|
|
74
|
+
const s = await stat(path);
|
|
75
|
+
return s.mode & 0o777;
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
if (err.code === 'ENOENT')
|
|
79
|
+
return null;
|
|
80
|
+
throw err;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
//# sourceMappingURL=config.js.map
|