@myapihq/cli 2.2.0 → 2.4.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.
Files changed (51) hide show
  1. package/dist/commands/account.d.ts +1 -1
  2. package/dist/commands/account.js +1 -0
  3. package/dist/commands/authproduct.js +7 -3
  4. package/dist/commands/billing.js +7 -2
  5. package/dist/commands/container.js +4 -3
  6. package/dist/commands/database.js +4 -3
  7. package/dist/commands/domain.js +39 -14
  8. package/dist/commands/email/message.js +6 -3
  9. package/dist/commands/email/template.js +3 -3
  10. package/dist/commands/fn.js +4 -3
  11. package/dist/commands/funnel.js +33 -13
  12. package/dist/commands/git.js +108 -35
  13. package/dist/commands/image.js +7 -4
  14. package/dist/commands/keys-validation.test.js +5 -0
  15. package/dist/commands/keys.js +6 -2
  16. package/dist/commands/llm.js +41 -20
  17. package/dist/commands/login-validation.test.d.ts +1 -0
  18. package/dist/commands/login-validation.test.js +43 -0
  19. package/dist/commands/login.d.ts +14 -0
  20. package/dist/commands/login.js +447 -0
  21. package/dist/commands/org.d.ts +1 -1
  22. package/dist/commands/org.js +24 -7
  23. package/dist/commands/queue.js +4 -3
  24. package/dist/commands/setup.js +6 -1
  25. package/dist/commands/storage.js +1 -1
  26. package/dist/commands/workflow.js +5 -4
  27. package/dist/completion.js +3 -3
  28. package/dist/config.js +3 -0
  29. package/dist/errors.d.ts +3 -0
  30. package/dist/errors.js +62 -0
  31. package/dist/errors.test.d.ts +1 -0
  32. package/dist/errors.test.js +28 -0
  33. package/dist/exposes.test.js +1 -0
  34. package/dist/flags.js +5 -3
  35. package/dist/flags.test.js +1 -1
  36. package/dist/helpers.d.ts +1 -0
  37. package/dist/helpers.js +22 -0
  38. package/dist/index.js +10 -51
  39. package/dist/skills/my-api-hq/SKILL.md +2 -2
  40. package/dist/skills/my-domain-api/SKILL.md +5 -5
  41. package/dist/skills/my-email-verify-api/SKILL.md +6 -4
  42. package/dist/skills/my-funnel-api/SKILL.md +5 -4
  43. package/dist/skills/my-git-api/SKILL.md +14 -7
  44. package/dist/skills/my-image-api/SKILL.md +1 -1
  45. package/dist/skills/my-llm-api/SKILL.md +11 -11
  46. package/dist/skills/my-storage-api/SKILL.md +5 -5
  47. package/dist/skills/my-webhook-api/SKILL.md +1 -1
  48. package/dist/skills/my-workflow-api/SKILL.md +1 -1
  49. package/dist/utils.d.ts +1 -0
  50. package/dist/utils.js +13 -1
  51. package/package.json +3 -2
@@ -1,7 +1,7 @@
1
1
  import { image as sdkImage } from '@myapihq/sdk';
2
2
  import { requireConfig } from '../config.js';
3
3
  import { success, error, printTable, info, printJson, banner } from '../output.js';
4
- import { formatDate, pollJob } from '../utils.js';
4
+ import { formatDate, pollJob, retryFunds } from '../utils.js';
5
5
  import { requireOrg, requireArg } from '../helpers.js';
6
6
  export const EXPOSES = [
7
7
  'POST /image/orgs/{org_id}/generate',
@@ -63,7 +63,10 @@ async function generate(promptArg, flags) {
63
63
  // Cost is per-model — see `myapi image models`. Banner shown on stderr so
64
64
  // --json output stays clean for piping.
65
65
  banner(`› Generating image${payload.model ? ` (${payload.model})` : ''}…`);
66
- const job = await sdkImage.generateImage(config.api_key, orgId, payload);
66
+ const job = await retryFunds(() => sdkImage.generateImage(config.api_key, orgId, payload));
67
+ // Surface the job id BEFORE polling — the job is already charged, so a
68
+ // poll timeout must leave the user a handle to recover it.
69
+ banner(`› Job ${job.job_id} submitted.`);
67
70
  const final = await pollJob({
68
71
  label: 'Generating image',
69
72
  timeoutMs: 90_000,
@@ -72,7 +75,7 @@ async function generate(promptArg, flags) {
72
75
  isDone: s => s.status === 'completed',
73
76
  isFailed: s => s.status === 'failed',
74
77
  failedMessage: 'Image generation failed',
75
- timeoutMessage: 'Image generation timed out — re-run "myapi image get <id>" to check later.',
78
+ timeoutMessage: `Image generation timed out — the job may still complete. Check later with: myapi image get ${job.job_id}`,
76
79
  });
77
80
  if (flags.json) {
78
81
  printJson(final);
@@ -207,7 +210,7 @@ The asset lands in your org's storage and is also visible via "myapi storage lis
207
210
  if (usage)
208
211
  info(`Usage: ${usage}`);
209
212
  else
210
- error(`Unknown subcommand: ${subcommand}. Run "myapi image --help" for the list.`);
213
+ info(`Unknown subcommand: ${subcommand}. Run "myapi image --help" for the list.`);
211
214
  return;
212
215
  }
213
216
  switch (subcommand) {
@@ -48,6 +48,11 @@ describe('_parseGrants', () => {
48
48
  expect(typeof r).toBe('string');
49
49
  expect(r).toMatch(/Invalid access "admin"/);
50
50
  });
51
+ it('extra colon segments — no silent truncation to the first pair', () => {
52
+ const r = _parseGrants('email:read:write');
53
+ expect(typeof r).toBe('string');
54
+ expect(r).toMatch(/Invalid access "read:write"/);
55
+ });
51
56
  it('an empty string', () => {
52
57
  expect(typeof _parseGrants('')).toBe('string');
53
58
  expect(_parseGrants('')).toMatch(/--grant was empty/);
@@ -30,7 +30,11 @@ const KEY_KINDS = ['function', 'manual', 'account'];
30
30
  export function _parseGrants(raw) {
31
31
  const grants = {};
32
32
  for (const part of raw.split(',').map(s => s.trim()).filter(Boolean)) {
33
- const [slot, accessRaw] = part.includes(':') ? part.split(':', 2) : [part, 'write'];
33
+ // Split on the FIRST colon only, keeping the rest intact so malformed
34
+ // grants like "email:read:write" fail validation instead of silently
35
+ // truncating to "email:read".
36
+ const colon = part.indexOf(':');
37
+ const [slot, accessRaw] = colon >= 0 ? [part.slice(0, colon), part.slice(colon + 1)] : [part, 'write'];
34
38
  if (!VALID_GRANT_TARGETS.has(slot)) {
35
39
  return `Unknown slot "${slot}" in --grant. Valid slots: ${[...hq.GRANTABLE_SLOTS].join(', ')} (or "*").`;
36
40
  }
@@ -87,7 +91,7 @@ function describeKey(k) {
87
91
  // ── Subcommands ──────────────────────────────────────────────────────────────
88
92
  export async function createNew(flags) {
89
93
  const config = requireConfig();
90
- let name = flags.name || '';
94
+ let name = typeof flags.name === 'string' ? flags.name : '';
91
95
  if (!name)
92
96
  name = await ask('Enter a name for the new key: ');
93
97
  if (!name.trim())
@@ -3,6 +3,7 @@ import { llm as sdkLlm } from '@myapihq/sdk';
3
3
  import { requireConfig } from '../config.js';
4
4
  import { error, info, printTable, printJson } from '../output.js';
5
5
  import { requireOrg } from '../helpers.js';
6
+ import { retryFunds } from '../utils.js';
6
7
  export const EXPOSES = [
7
8
  'POST /llm/orgs/{org_id}/complete',
8
9
  'POST /llm/orgs/{org_id}/embed',
@@ -47,6 +48,25 @@ async function defaultChatModel(apiKey, orgId) {
47
48
  }
48
49
  return chat.id;
49
50
  }
51
+ // Resolve the embed model from --model, else the live catalog. Unlike chat
52
+ // we don't silently pick a default when several are served — embedding
53
+ // vectors aren't cross-model compatible, so an ambiguous pick is a footgun.
54
+ // One served embed model → use it; none → catalog-truthful error (not a
55
+ // hardcoded "not available today"); many → make the caller choose.
56
+ async function resolveEmbedModel(apiKey, orgId, flags) {
57
+ if (typeof flags.model === 'string' && flags.model)
58
+ return flags.model;
59
+ const embeds = (await loadModels(apiKey, orgId)).filter(m => m.kind === 'embed');
60
+ if (embeds.length === 1)
61
+ return embeds[0].id;
62
+ if (embeds.length === 0) {
63
+ error('No embedding model is currently served — the catalog has none. Run "myapi llm models --kind embed" to check.');
64
+ }
65
+ else {
66
+ error(`Multiple embedding models available — pass --model <id>. Options: ${embeds.map(m => m.id).join(', ')}`);
67
+ }
68
+ throw new Error('unreachable'); // satisfy the type checker
69
+ }
50
70
  function readPromptArg(promptArg, flags) {
51
71
  if (typeof flags.file === 'string' && flags.file) {
52
72
  return fs.readFileSync(flags.file, 'utf-8');
@@ -123,13 +143,13 @@ async function complete(promptArg, flags) {
123
143
  const stop = typeof flags.stop === 'string' && flags.stop
124
144
  ? flags.stop.split(',').map(s => s.trim()).filter(Boolean)
125
145
  : undefined;
126
- const res = await sdkLlm.complete(config.api_key, orgId, {
146
+ const res = await retryFunds(() => sdkLlm.complete(config.api_key, orgId, {
127
147
  model,
128
148
  messages,
129
149
  max_tokens: typeof flags['max-tokens'] === 'number' ? flags['max-tokens'] : undefined,
130
150
  temperature: typeof flags.temperature === 'number' ? flags.temperature : undefined,
131
151
  stop,
132
- });
152
+ }));
133
153
  if (flags.json) {
134
154
  printJson(res);
135
155
  return;
@@ -140,14 +160,12 @@ async function complete(promptArg, flags) {
140
160
  }
141
161
  async function embed(inputArg, flags) {
142
162
  const config = requireConfig();
143
- const orgId = requireOrg(flags, config, 'myapi llm embed "<text>" --model <id> [--org <id>]');
144
- const model = typeof flags.model === 'string' ? flags.model : '';
145
- if (!model)
146
- error('Missing required flag: --model. Run "myapi llm models" to list embed models.');
163
+ const orgId = requireOrg(flags, config, 'myapi llm embed "<text>" [--model <id>] [--org <id>]');
164
+ const model = await resolveEmbedModel(config.api_key, orgId, flags);
147
165
  const text = readPromptArg(inputArg, flags);
148
166
  if (!text.trim())
149
167
  error('Empty input. Pass <text> as an argument, "-" to read stdin, or --file <path>.');
150
- const res = await sdkLlm.embed(config.api_key, orgId, { model, input: text });
168
+ const res = await retryFunds(() => sdkLlm.embed(config.api_key, orgId, { model, input: text }));
151
169
  if (flags.json) {
152
170
  printJson(res);
153
171
  return;
@@ -194,7 +212,7 @@ async function classify(inputArg, flags) {
194
212
  if (labels.length === 0)
195
213
  error('--labels must contain at least one label');
196
214
  const multi = flags.multi === true;
197
- const res = await sdkLlm.classify(config.api_key, orgId, { input, labels, multi, tier: tierFromFlag(flags) });
215
+ const res = await retryFunds(() => sdkLlm.classify(config.api_key, orgId, { input, labels, multi, tier: tierFromFlag(flags) }));
198
216
  if (flags.json) {
199
217
  printJson(res);
200
218
  return;
@@ -219,7 +237,7 @@ async function extract(inputArg, flags) {
219
237
  error('Missing required flag: --schema <path|json>');
220
238
  return;
221
239
  }
222
- const res = await sdkLlm.extract(config.api_key, orgId, { input, schema, tier: tierFromFlag(flags) });
240
+ const res = await retryFunds(() => sdkLlm.extract(config.api_key, orgId, { input, schema, tier: tierFromFlag(flags) }));
223
241
  if (flags.json) {
224
242
  printJson(res);
225
243
  return;
@@ -237,11 +255,11 @@ async function summarize(inputArg, flags) {
237
255
  if (style && !['brief', 'exec', 'bullet'].includes(style)) {
238
256
  error('--style must be one of: brief, exec, bullet');
239
257
  }
240
- const res = await sdkLlm.summarize(config.api_key, orgId, {
258
+ const res = await retryFunds(() => sdkLlm.summarize(config.api_key, orgId, {
241
259
  input,
242
260
  style: style,
243
261
  tier: tierFromFlag(flags),
244
- });
262
+ }));
245
263
  if (flags.json) {
246
264
  printJson(res);
247
265
  return;
@@ -256,19 +274,22 @@ async function draft(inputArg, flags) {
256
274
  error('Missing required flag: --kind <email|message|reply|...>');
257
275
  return;
258
276
  }
277
+ // Capture the narrowed value — typeof narrowing doesn't survive into the
278
+ // retryFunds closure below.
279
+ const kind = flags.kind;
259
280
  const input = readPromptArg(inputArg, flags);
260
281
  const promptText = typeof flags.prompt === 'string' ? flags.prompt : '';
261
282
  const ctx = parseContextFlag(flags);
262
283
  if (!input.trim() && !promptText.trim() && (!ctx || Object.keys(ctx).length === 0)) {
263
284
  error('draft needs at least one of: <source text> (arg or --file), --prompt, or --context.');
264
285
  }
265
- const res = await sdkLlm.draft(config.api_key, orgId, {
286
+ const res = await retryFunds(() => sdkLlm.draft(config.api_key, orgId, {
266
287
  input: input || undefined,
267
- kind: flags.kind,
288
+ kind,
268
289
  context: ctx,
269
290
  prompt: promptText || undefined,
270
291
  tier: tierFromFlag(flags),
271
- });
292
+ }));
272
293
  if (flags.json) {
273
294
  printJson(res);
274
295
  return;
@@ -283,8 +304,7 @@ const SUBCOMMAND_USAGE = {
283
304
  [--stop <csv>] [--file <path>] [--org <id>] [--json]
284
305
 
285
306
  Raw chat completion against a self-hosted catalog model. Without --model,
286
- picks the first chat model from "myapi llm models" (today:
287
- Qwen/Qwen3-Coder-30B-A3B-Instruct).
307
+ picks the first chat model from "myapi llm models".
288
308
 
289
309
  Pass "-" as the prompt to read from stdin. The reply goes to stdout; a
290
310
  one-line usage footer (tokens + cost in cents) goes to stderr so it
@@ -293,10 +313,11 @@ const SUBCOMMAND_USAGE = {
293
313
  Examples:
294
314
  myapi llm complete "summarize: $(cat README.md)"
295
315
  cat draft.md | myapi llm complete - --system "You are an editor" --max-tokens 200`,
296
- embed: `myapi llm embed "<text>" --model <id> [--file <path>] [--org <id>] [--json]
316
+ embed: `myapi llm embed "<text>" [--model <id>] [--file <path>] [--org <id>] [--json]
297
317
 
298
- No embedding model is served on the raw catalog today — this returns
299
- EMBED_NOT_AVAILABLE until one is. Use a dedicated embedding API for now.`,
318
+ Embed text into a dense vector. --model is optional when the catalog serves
319
+ exactly one embedding model; otherwise pass one from "myapi llm models
320
+ --kind embed". Returns EMBED_NOT_AVAILABLE if no embedding model is served.`,
300
321
  models: `myapi llm models [--kind chat|embed] [--org <id>] [--json]
301
322
 
302
323
  Lists the live model catalog with per-1M-token pricing in cents.
@@ -358,7 +379,7 @@ Subcommands:
358
379
  classify Pick a label from a set
359
380
  complete Raw chat completion against a catalog model
360
381
  draft Write something (email | reply | message | …)
361
- embed Raw embeddings (no model served on raw today)
382
+ embed Raw embeddings against a catalog embed model
362
383
  extract Pull structured data conforming to a JSON Schema
363
384
  models List the live model catalog (id, kind, context, cents/1M)
364
385
  summarize Summarize text (brief | exec | bullet)
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,43 @@
1
+ // Unit tests for the login command's pure PKCE/URL helpers.
2
+ import { describe, it, expect } from 'vitest';
3
+ import * as crypto from 'crypto';
4
+ import { generatePkce, buildAuthorizeUrl } from './login.js';
5
+ describe('generatePkce', () => {
6
+ it('challenge is base64url(sha256(verifier)) — S256', () => {
7
+ const { verifier, challenge } = generatePkce();
8
+ const expected = crypto.createHash('sha256').update(verifier).digest('base64url');
9
+ expect(challenge).toBe(expected);
10
+ });
11
+ it('verifier and state are base64url with sufficient entropy', () => {
12
+ const { verifier, state } = generatePkce();
13
+ // 32 random bytes → 43 base64url chars; 16 → 22. No padding, URL-safe.
14
+ expect(verifier).toMatch(/^[A-Za-z0-9_-]{43}$/);
15
+ expect(state).toMatch(/^[A-Za-z0-9_-]{22}$/);
16
+ });
17
+ it('never repeats across calls', () => {
18
+ const a = generatePkce();
19
+ const b = generatePkce();
20
+ expect(a.verifier).not.toBe(b.verifier);
21
+ expect(a.state).not.toBe(b.state);
22
+ });
23
+ });
24
+ describe('buildAuthorizeUrl', () => {
25
+ it('carries the full OIDC + PKCE parameter set', () => {
26
+ const pkce = generatePkce();
27
+ const redirect = 'http://127.0.0.1:49152/callback';
28
+ const u = new URL(buildAuthorizeUrl('http://127.0.0.1:9999/authorize', redirect, pkce));
29
+ expect(u.pathname).toBe('/authorize');
30
+ expect(u.searchParams.get('client_id')).toBe('cl_operator_cli');
31
+ expect(u.searchParams.get('response_type')).toBe('code');
32
+ expect(u.searchParams.get('redirect_uri')).toBe(redirect);
33
+ expect(u.searchParams.get('state')).toBe(pkce.state);
34
+ expect(u.searchParams.get('code_challenge')).toBe(pkce.challenge);
35
+ expect(u.searchParams.get('code_challenge_method')).toBe('S256');
36
+ expect(u.searchParams.get('scope')).toBe('openid email profile');
37
+ });
38
+ it('preserves the loopback port in redirect_uri (RFC 8252 any-port)', () => {
39
+ const pkce = generatePkce();
40
+ const u = new URL(buildAuthorizeUrl('http://127.0.0.1:1234/authorize', 'http://127.0.0.1:65530/callback', pkce));
41
+ expect(u.searchParams.get('redirect_uri')).toContain(':65530');
42
+ });
43
+ });
@@ -0,0 +1,14 @@
1
+ import type { FlagSchema } from '../flags.js';
2
+ import type { Flags } from '../helpers.js';
3
+ import type { Exposes } from '../exposes.js';
4
+ export declare const EXPOSES: Exposes;
5
+ export declare const SCHEMA: FlagSchema;
6
+ export declare const HELP = "Usage: myapi login [--no-browser] [--mock]\n\nSigns in to MyAPI in your browser \u2014 Google, or an email code \u2014 and stores the\nsession in ~/.myapi/config.json. The account created/linked here is the same\naccount model as `myapi account setup`; anonymous setup stays available and\nunchanged.\n\nFlags:\n --no-browser Print the sign-in URL instead of opening a browser\n --mock Run the flow against an in-process mock (offline UX preview;\n nothing is saved, no network calls leave your machine)";
7
+ export interface Pkce {
8
+ verifier: string;
9
+ challenge: string;
10
+ state: string;
11
+ }
12
+ export declare function generatePkce(): Pkce;
13
+ export declare function buildAuthorizeUrl(base: string, redirectUri: string, pkce: Pkce): string;
14
+ export declare function login(flags?: Flags): Promise<void>;