@myapihq/cli 2.3.1 → 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 (50) 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 +14 -10
  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-storage-api/SKILL.md +5 -5
  46. package/dist/skills/my-webhook-api/SKILL.md +1 -1
  47. package/dist/skills/my-workflow-api/SKILL.md +1 -1
  48. package/dist/utils.d.ts +1 -0
  49. package/dist/utils.js +13 -1
  50. 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',
@@ -142,13 +143,13 @@ async function complete(promptArg, flags) {
142
143
  const stop = typeof flags.stop === 'string' && flags.stop
143
144
  ? flags.stop.split(',').map(s => s.trim()).filter(Boolean)
144
145
  : undefined;
145
- const res = await sdkLlm.complete(config.api_key, orgId, {
146
+ const res = await retryFunds(() => sdkLlm.complete(config.api_key, orgId, {
146
147
  model,
147
148
  messages,
148
149
  max_tokens: typeof flags['max-tokens'] === 'number' ? flags['max-tokens'] : undefined,
149
150
  temperature: typeof flags.temperature === 'number' ? flags.temperature : undefined,
150
151
  stop,
151
- });
152
+ }));
152
153
  if (flags.json) {
153
154
  printJson(res);
154
155
  return;
@@ -164,7 +165,7 @@ async function embed(inputArg, flags) {
164
165
  const text = readPromptArg(inputArg, flags);
165
166
  if (!text.trim())
166
167
  error('Empty input. Pass <text> as an argument, "-" to read stdin, or --file <path>.');
167
- 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 }));
168
169
  if (flags.json) {
169
170
  printJson(res);
170
171
  return;
@@ -211,7 +212,7 @@ async function classify(inputArg, flags) {
211
212
  if (labels.length === 0)
212
213
  error('--labels must contain at least one label');
213
214
  const multi = flags.multi === true;
214
- 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) }));
215
216
  if (flags.json) {
216
217
  printJson(res);
217
218
  return;
@@ -236,7 +237,7 @@ async function extract(inputArg, flags) {
236
237
  error('Missing required flag: --schema <path|json>');
237
238
  return;
238
239
  }
239
- 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) }));
240
241
  if (flags.json) {
241
242
  printJson(res);
242
243
  return;
@@ -254,11 +255,11 @@ async function summarize(inputArg, flags) {
254
255
  if (style && !['brief', 'exec', 'bullet'].includes(style)) {
255
256
  error('--style must be one of: brief, exec, bullet');
256
257
  }
257
- const res = await sdkLlm.summarize(config.api_key, orgId, {
258
+ const res = await retryFunds(() => sdkLlm.summarize(config.api_key, orgId, {
258
259
  input,
259
260
  style: style,
260
261
  tier: tierFromFlag(flags),
261
- });
262
+ }));
262
263
  if (flags.json) {
263
264
  printJson(res);
264
265
  return;
@@ -273,19 +274,22 @@ async function draft(inputArg, flags) {
273
274
  error('Missing required flag: --kind <email|message|reply|...>');
274
275
  return;
275
276
  }
277
+ // Capture the narrowed value — typeof narrowing doesn't survive into the
278
+ // retryFunds closure below.
279
+ const kind = flags.kind;
276
280
  const input = readPromptArg(inputArg, flags);
277
281
  const promptText = typeof flags.prompt === 'string' ? flags.prompt : '';
278
282
  const ctx = parseContextFlag(flags);
279
283
  if (!input.trim() && !promptText.trim() && (!ctx || Object.keys(ctx).length === 0)) {
280
284
  error('draft needs at least one of: <source text> (arg or --file), --prompt, or --context.');
281
285
  }
282
- const res = await sdkLlm.draft(config.api_key, orgId, {
286
+ const res = await retryFunds(() => sdkLlm.draft(config.api_key, orgId, {
283
287
  input: input || undefined,
284
- kind: flags.kind,
288
+ kind,
285
289
  context: ctx,
286
290
  prompt: promptText || undefined,
287
291
  tier: tierFromFlag(flags),
288
- });
292
+ }));
289
293
  if (flags.json) {
290
294
  printJson(res);
291
295
  return;
@@ -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>;