@vorlek/cli 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,8 +18,10 @@ the CLI.
18
18
  vorlek auth signup
19
19
  vorlek auth use --api-key vk_live_...
20
20
  vorlek connect sendgrid --api-key SG....
21
+ vorlek connect reconnect klaviyo --api-key pk_...
21
22
  vorlek catalog
22
23
  vorlek contact upsert --provider sendgrid --email person@example.com --detail full
24
+ vorlek contact upsert-all --email person@example.com --providers sendgrid,mailchimp,klaviyo --detail full
23
25
  vorlek contact get --provider sendgrid --email person@example.com
24
26
  vorlek operation get 01HV0000000000000000000000
25
27
  vorlek template list --provider sendgrid
@@ -30,4 +32,6 @@ vorlek status --check
30
32
 
31
33
  Tool commands accept `--detail minimal|standard|full` where the API supports
32
34
  response verbosity. Use `operation get` with the `meta.request_id` returned by
33
- API responses to inspect a stored operation receipt.
35
+ API and CLI JSON responses to inspect a stored operation receipt. `contact
36
+ upsert-all` writes the same contact to every connected provider by default, or
37
+ to the comma-separated `--providers` list when provided.
package/dist/cli.js CHANGED
@@ -23,7 +23,7 @@ import { defaultDeps } from './deps.js';
23
23
  */
24
24
  const cli = cac('vorlek');
25
25
  cli.help();
26
- cli.version('0.1.0');
26
+ cli.version('0.2.1');
27
27
  cli.option('--api-base <url>', 'Override API base URL (default: from config or https://api.vorlek.com)');
28
28
  cli.option('--json', 'Output raw JSON (machine-readable)');
29
29
  cli.option('-v, --verbose', 'Verbose logging (includes stack traces on errors)');
package/dist/client.js CHANGED
@@ -42,7 +42,7 @@ export function createApiClient(opts) {
42
42
  ],
43
43
  },
44
44
  });
45
- async function call(path, init) {
45
+ async function callResult(path, init) {
46
46
  const res = await http(path, init);
47
47
  const text = await res.text();
48
48
  let body;
@@ -63,17 +63,22 @@ export function createApiClient(opts) {
63
63
  throw new ApiError(res.status, body);
64
64
  }
65
65
  if (obj.status === 'success' && 'data' in obj) {
66
- return body.data;
66
+ const envelope = body;
67
+ return { data: envelope.data, meta: envelope.meta, tip: envelope.tip };
67
68
  }
68
69
  // Non-2xx without an error envelope = something unusual; surface it.
69
70
  if (res.status < 200 || res.status >= 300) {
70
71
  throw new Error(`Server returned status ${res.status} without an error envelope: ${text.slice(0, 200)}`);
71
72
  }
72
73
  // Flat success — body itself is the response.
73
- return body;
74
+ return { data: body };
74
75
  }
75
76
  throw new Error(`Server returned a non-object response: ${text.slice(0, 200)}`);
76
77
  }
78
+ async function call(path, init) {
79
+ const result = await callResult(path, init);
80
+ return result.data;
81
+ }
77
82
  function toolPostInit(input) {
78
83
  const { idempotencyKey, detail, ...json } = input;
79
84
  return {
@@ -96,6 +101,12 @@ export function createApiClient(opts) {
96
101
  createConnection(input) {
97
102
  return call('v1/connections', { method: 'POST', json: input });
98
103
  },
104
+ updateConnection(provider, input) {
105
+ return call(`v1/connections/${provider}`, {
106
+ method: 'PATCH',
107
+ json: input,
108
+ });
109
+ },
99
110
  listConnections() {
100
111
  return call('v1/connections', { method: 'GET' });
101
112
  },
@@ -115,6 +126,9 @@ export function createApiClient(opts) {
115
126
  upsertContact(input) {
116
127
  return call('v1/tools/upsert_contact', toolPostInit(input));
117
128
  },
129
+ upsertContactWithMeta(input) {
130
+ return callResult('v1/tools/upsert_contact', toolPostInit(input));
131
+ },
118
132
  getContact(input) {
119
133
  return call('v1/tools/get_contact', toolPostInit(input));
120
134
  },
@@ -20,24 +20,37 @@ function availableAudiencesFromError(err) {
20
20
  function connectionPayload(provider, opts, listId) {
21
21
  return {
22
22
  provider,
23
+ ...credentialPayload(provider, opts, listId),
24
+ };
25
+ }
26
+ function credentialPayload(provider, opts, listId) {
27
+ return {
23
28
  api_key: opts.apiKey ?? '',
24
29
  ...(provider === 'mailchimp' && listId ? { list_id: listId } : {}),
25
30
  };
26
31
  }
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)}`);
32
+ function renderConnectSuccess(deps, ctx, provider, conn, verb = 'Connected') {
33
+ renderSuccess(deps, ctx, conn, () => `${pc.green('✓')} ${verb} ${pc.bold(provider)} ${conn.account_name ? `(${pc.dim(conn.account_name)}) ` : ''}— connection_id ${pc.dim(conn.connection_id)}`);
34
+ }
35
+ function validateConnectInput(provider, opts) {
36
+ if (!opts.apiKey)
37
+ throw new Error('Missing --api-key flag.');
38
+ if (provider === 'klaviyo') {
39
+ if (opts.listId)
40
+ throw new Error('Klaviyo connections do not use --list-id.');
41
+ }
42
+ }
43
+ function klaviyoPublicKeyWarning(opts, ctx) {
44
+ return !ctx.json && opts.apiKey?.startsWith('pub_')
45
+ ? `${pc.yellow('warning')} Klaviyo public keys start with pub_; use a private key starting with pk_ for write operations.\n`
46
+ : undefined;
29
47
  }
30
48
  export async function runConnect(deps, ctx, provider, opts) {
31
49
  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
- }
50
+ validateConnectInput(provider, opts);
51
+ const warning = provider === 'klaviyo' ? klaviyoPublicKeyWarning(opts, ctx) : undefined;
52
+ if (warning)
53
+ deps.stderr.write(warning);
41
54
  const cfg = await deps.loadConfig();
42
55
  if (!cfg)
43
56
  throw new ConfigMissingError();
@@ -69,6 +82,48 @@ export async function runConnect(deps, ctx, provider, opts) {
69
82
  deps.exit(renderError(deps, ctx, err));
70
83
  }
71
84
  }
85
+ export async function runReconnect(deps, ctx, provider, opts) {
86
+ try {
87
+ if (!provider)
88
+ throw new Error('Missing <provider> argument: `vorlek connect reconnect <provider>`.');
89
+ validateConnectInput(provider, opts);
90
+ const warning = provider === 'klaviyo' ? klaviyoPublicKeyWarning(opts, ctx) : undefined;
91
+ if (warning)
92
+ deps.stderr.write(warning);
93
+ const cfg = await deps.loadConfig();
94
+ if (!cfg)
95
+ throw new ConfigMissingError();
96
+ const apiBase = ctx.apiBaseOverride ?? cfg.api_base;
97
+ const client = deps.createApiClient({ apiBase, apiKey: cfg.api_key });
98
+ if (!client.updateConnection) {
99
+ throw new Error('Configured API client does not support connection reconnect.');
100
+ }
101
+ try {
102
+ const conn = await client.updateConnection(provider, credentialPayload(provider, opts, opts.listId));
103
+ renderConnectSuccess(deps, ctx, provider, conn, 'Reconnected');
104
+ }
105
+ catch (err) {
106
+ const audiences = availableAudiencesFromError(err);
107
+ if (provider !== 'mailchimp' || ctx.json || opts.listId || audiences.length === 0)
108
+ throw err;
109
+ const selected = await deps.prompts.select({
110
+ message: 'Select Mailchimp audience',
111
+ options: audiences.map((audience) => ({
112
+ value: audience.id,
113
+ label: audience.name,
114
+ hint: audience.id,
115
+ })),
116
+ });
117
+ if (deps.prompts.isCancel(selected))
118
+ throw new Error('Audience selection cancelled.');
119
+ const conn = await client.updateConnection(provider, credentialPayload(provider, opts, String(selected)));
120
+ renderConnectSuccess(deps, ctx, provider, conn, 'Reconnected');
121
+ }
122
+ }
123
+ catch (err) {
124
+ deps.exit(renderError(deps, ctx, err));
125
+ }
126
+ }
72
127
  export async function runList(deps, ctx) {
73
128
  try {
74
129
  const cfg = await deps.loadConfig();
@@ -111,7 +166,7 @@ export async function runRemove(deps, ctx, provider) {
111
166
  }
112
167
  export function registerConnectCommands(cli, deps, ctx) {
113
168
  cli
114
- .command('connect <action> [target]', 'Connect: <provider> --api-key <k> | list | remove <provider>')
169
+ .command('connect <action> [target]', 'Connect: <provider> --api-key <k> | reconnect <provider> --api-key <k> | list | remove <provider>')
115
170
  .option('--api-key <key>', 'Provider API key (required for connect <provider>)')
116
171
  .option('--list-id <id>', 'Mailchimp audience/list id')
117
172
  .action(async (action, target, opts) => {
@@ -119,6 +174,8 @@ export function registerConnectCommands(cli, deps, ctx) {
119
174
  return runList(deps, ctx);
120
175
  if (action === 'remove')
121
176
  return runRemove(deps, ctx, target);
177
+ if (action === 'reconnect')
178
+ return runReconnect(deps, ctx, target, opts);
122
179
  // Otherwise treat `action` as the provider name → create.
123
180
  return runConnect(deps, ctx, action, opts);
124
181
  });
@@ -22,22 +22,9 @@ export async function runUpsertContact(deps, ctx, opts) {
22
22
  }
23
23
  provider = conns[0]?.provider;
24
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
- }
25
+ const properties = parseProperties(opts.properties);
39
26
  const detail = parseDetail(opts.detail);
40
- const result = await client.upsertContact({
27
+ const result = await upsertContactWithResult(client, {
41
28
  provider,
42
29
  email: opts.email,
43
30
  first_name: opts.firstName,
@@ -49,12 +36,47 @@ export async function runUpsertContact(deps, ctx, opts) {
49
36
  ...(detail ? { detail } : {}),
50
37
  ...(opts.idempotencyKey ? { idempotencyKey: opts.idempotencyKey } : {}),
51
38
  });
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(', ')})`)}`
39
+ renderSuccess(deps, ctx, result.data, () => {
40
+ const fieldsHint = result.data.fields_auto_created.length > 0
41
+ ? ` ${pc.dim(`(auto-created fields: ${result.data.fields_auto_created.join(', ')})`)}`
55
42
  : '';
56
- return `${pc.green('✓')} Upserted contact ${pc.bold(result.contact_id)} (${result.action}) via ${pc.bold(result.provider)}.${fieldsHint}`;
57
- });
43
+ return `${pc.green('✓')} Upserted contact ${pc.bold(result.data.contact_id)} (${result.data.action}) via ${pc.bold(result.data.provider)}.${fieldsHint}`;
44
+ }, result.meta);
45
+ }
46
+ catch (err) {
47
+ deps.exit(renderError(deps, ctx, err));
48
+ }
49
+ }
50
+ export async function runUpsertAllContacts(deps, ctx, opts) {
51
+ try {
52
+ if (!opts.email)
53
+ throw new Error('Missing --email flag.');
54
+ const cfg = await deps.loadConfig();
55
+ if (!cfg)
56
+ throw new ConfigMissingError();
57
+ const apiBase = ctx.apiBaseOverride ?? cfg.api_base;
58
+ const client = deps.createApiClient({ apiBase, apiKey: cfg.api_key });
59
+ const providers = await resolveTargetProviders(client, opts);
60
+ if (opts.idempotencyKey && providers.length > 1) {
61
+ throw new Error('--idempotency-key cannot be reused across multiple providers. Omit it or pass a single --provider/--providers value.');
62
+ }
63
+ const properties = parseProperties(opts.properties);
64
+ const detail = parseDetail(opts.detail);
65
+ const results = [];
66
+ for (const provider of providers) {
67
+ const result = await upsertContactWithResult(client, {
68
+ provider,
69
+ email: opts.email,
70
+ first_name: opts.firstName,
71
+ last_name: opts.lastName,
72
+ phone: opts.phone === undefined ? undefined : String(opts.phone),
73
+ properties,
74
+ ...(detail ? { detail } : {}),
75
+ ...(opts.idempotencyKey ? { idempotencyKey: opts.idempotencyKey } : {}),
76
+ });
77
+ results.push({ ...result.data, ...(result.meta ? { meta: result.meta } : {}) });
78
+ }
79
+ renderSuccess(deps, ctx, { email: opts.email, results }, () => renderUpsertAllContacts(opts.email ?? '', results));
58
80
  }
59
81
  catch (err) {
60
82
  deps.exit(renderError(deps, ctx, err));
@@ -93,21 +115,24 @@ export async function runGetContact(deps, ctx, opts) {
93
115
  }
94
116
  export function registerContactCommands(cli, deps, ctx) {
95
117
  cli
96
- .command('contact <action>', 'Contact operations: upsert, get')
118
+ .command('contact <action>', 'Contact operations: upsert, upsert-all, get')
97
119
  .option('--email <email>', 'Contact email (required for upsert and get)')
98
120
  .option('--first-name <name>', 'First name')
99
121
  .option('--last-name <name>', 'Last name')
100
122
  .option('--phone <phone>', 'Phone number')
101
123
  .option('--properties <json>', 'Custom properties as JSON, e.g. \'{"plan":"gold","tier":"silver"}\'')
102
124
  .option('--provider <name>', 'Provider override (defaults to auto-detect)')
125
+ .option('--providers <list>', 'Comma-separated providers for upsert-all')
103
126
  .option('--detail <level>', 'Response detail: minimal, standard, full')
104
127
  .option('--idempotency-key <key>', 'Override the auto-generated idempotency key')
105
128
  .action(async (action, opts) => {
106
129
  if (action === 'upsert')
107
130
  return runUpsertContact(deps, ctx, opts);
131
+ if (action === 'upsert-all')
132
+ return runUpsertAllContacts(deps, ctx, opts);
108
133
  if (action === 'get')
109
134
  return runGetContact(deps, ctx, opts);
110
- deps.stderr.write(`${pc.red('error')} Unknown contact action: ${action}. Try: upsert, get\n`);
135
+ deps.stderr.write(`${pc.red('error')} Unknown contact action: ${action}. Try: upsert, upsert-all, get\n`);
111
136
  deps.exit(1);
112
137
  });
113
138
  }
@@ -117,4 +142,58 @@ function contactIdFromResult(contact) {
117
142
  const contactId = contact.contact_id;
118
143
  return typeof contactId === 'string' ? contactId : null;
119
144
  }
145
+ function parseProperties(raw) {
146
+ if (!raw)
147
+ return undefined;
148
+ try {
149
+ const parsed = JSON.parse(raw);
150
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
151
+ throw new Error('--properties must be a JSON object.');
152
+ }
153
+ return parsed;
154
+ }
155
+ catch (parseErr) {
156
+ const msg = parseErr instanceof Error ? parseErr.message : String(parseErr);
157
+ throw new Error(`--properties: ${msg}`);
158
+ }
159
+ }
160
+ async function upsertContactWithResult(client, input) {
161
+ if (client.upsertContactWithMeta)
162
+ return client.upsertContactWithMeta(input);
163
+ return { data: await client.upsertContact(input) };
164
+ }
165
+ async function resolveTargetProviders(client, opts) {
166
+ const listed = parseProviderList(opts.providers);
167
+ if (listed.length > 0)
168
+ return listed;
169
+ if (opts.provider)
170
+ return [opts.provider];
171
+ const providers = [...new Set((await client.listConnections()).map((conn) => conn.provider))];
172
+ if (providers.length === 0) {
173
+ throw new Error('No connections found. Run `vorlek connect <provider> --api-key ...` first.');
174
+ }
175
+ return providers;
176
+ }
177
+ function parseProviderList(raw) {
178
+ if (!raw)
179
+ return [];
180
+ const providers = raw
181
+ .split(',')
182
+ .map((provider) => provider.trim())
183
+ .filter((provider) => provider.length > 0);
184
+ if (providers.length === 0)
185
+ throw new Error('--providers must include at least one provider.');
186
+ return [...new Set(providers)];
187
+ }
188
+ function renderUpsertAllContacts(email, results) {
189
+ const lines = results.map((result) => {
190
+ const { meta } = result;
191
+ const receipt = meta?.request_id ? ` ${pc.dim(`request_id: ${meta.request_id}`)}` : '';
192
+ return ` ${pc.green('✓')} ${result.provider}: ${result.contact_id} (${result.action})${receipt}`;
193
+ });
194
+ return [
195
+ `${pc.green('✓')} Upserted ${pc.bold(email)} across ${results.length} provider${results.length === 1 ? '' : 's'}.`,
196
+ ...lines,
197
+ ].join('\n');
198
+ }
120
199
  //# sourceMappingURL=contact.js.map
@@ -46,6 +46,9 @@ export function registerStatusCommand(cli, deps, ctx) {
46
46
  const fresh = checksByProvider.get(c.provider);
47
47
  const checkText = fresh ? `, checked: ${fresh.status}` : '';
48
48
  lines.push(` ${pc.green('●')} ${c.provider} (${c.status}${checkText})${name}`);
49
+ if (fresh?.error) {
50
+ lines.push(` ${pc.yellow(fresh.error.code)}: ${fresh.error.message}`);
51
+ }
49
52
  }
50
53
  }
51
54
  lines.push('');
@@ -4,6 +4,8 @@ import { ApiError } from './client.js';
4
4
  export function renderSuccess(deps, ctx, data, human, meta) {
5
5
  if (ctx.json) {
6
6
  const out = { status: 'success', data };
7
+ if (meta)
8
+ out.meta = meta;
7
9
  if (meta?.request_id)
8
10
  out.request_id = meta.request_id;
9
11
  deps.stdout.write(`${JSON.stringify(out)}\n`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vorlek/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Vorlek CLI — thin REST API wrapper for the Vorlek aggregator",
5
5
  "license": "UNLICENSED",
6
6
  "homepage": "https://vorlek.dev/docs/",