@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
@@ -3,7 +3,7 @@ import type { Flags } from '../helpers.js';
3
3
  import type { Exposes } from '../exposes.js';
4
4
  export declare const EXPOSES: Exposes;
5
5
  export declare const SCHEMA: FlagSchema;
6
- export declare const HELP = "Usage: myapi account <subcommand>\n\nSubcommands:\n api-keys Manage API keys \u00B7 list / create / revoke\n config Manage CLI defaults (org, funnel, domain) \u00B7 supports set-org / set-funnel / set-domain\n import-key Import an existing API key non-interactively\n install-skills DEPRECATED \u2014 use `myapi install-skills` instead\n keys Alias for api-keys\n link [email] Upgrade anonymous account to registered (or add a second session)\n Use myapi account setup to create a completely new account\n mailing-address Get or set the account's mailing address (CAN-SPAM)\n registrant Manage stored WHOIS contact info for domain registration \u00B7 set / get / clear\n setup Configure your account\n switch [index] Switch active account by index or email\n whoami Show current account \u00B7 supports --json";
6
+ export declare const HELP = "Usage: myapi account <subcommand>\n\nSubcommands:\n api-keys Manage API keys \u00B7 list / create / revoke\n config Manage CLI defaults (org, funnel, domain) \u00B7 supports set-org / set-funnel / set-domain\n import-key Import an existing API key non-interactively\n install-skills DEPRECATED \u2014 use `myapi install-skills` instead\n keys Alias for api-keys\n link [email] Upgrade anonymous account to registered (or add a second session)\n Use myapi account setup to create a completely new account\n login Sign in via your browser \u2014 Google or email code (preview: --mock)\n mailing-address Get or set the account's mailing address (CAN-SPAM)\n registrant Manage stored WHOIS contact info for domain registration \u00B7 set / get / clear\n setup Configure your account\n switch [index] Switch active account by index or email\n whoami Show current account \u00B7 supports --json";
7
7
  export declare const INSTALL_SKILLS_HELP = "Usage: myapi install-skills\n\nInstalls the MyAPI skills pack for AI coding agents (Claude, Codex, Gemini, \u2026).\n\nThis command writes skill definition files to:\n ~/.agents/skills/myapi/\n\nAnd creates symlinks in the appropriate agent config directories:\n ~/.claude/ (Claude)\n ~/.gemini/ (Gemini)\n ~/.cursor/ (Cursor, if detected)\n\nThese files teach agents how to use the MyAPI CLI and API directly.\nRun this command again to update existing skills to the latest version.\n\nNote: `myapi account install-skills` is deprecated and will be removed in the next minor.\nUse `myapi install-skills` going forward.";
8
8
  export declare function link(flags?: Flags, emailArg?: string): Promise<void>;
9
9
  export declare function whoami(flags?: Flags): Promise<void>;
@@ -36,6 +36,7 @@ Subcommands:
36
36
  keys Alias for api-keys
37
37
  link [email] Upgrade anonymous account to registered (or add a second session)
38
38
  Use myapi account setup to create a completely new account
39
+ login Sign in via your browser — Google or email code (preview: --mock)
39
40
  mailing-address Get or set the account's mailing address (CAN-SPAM)
40
41
  registrant Manage stored WHOIS contact info for domain registration · set / get / clear
41
42
  setup Configure your account
@@ -1,4 +1,4 @@
1
- import { auth as sdkAuth } from '@myapihq/sdk';
1
+ import { auth as sdkAuth, MyApiError } from '@myapihq/sdk';
2
2
  import { requireConfig } from '../config.js';
3
3
  import { confirm, isNonInteractive } from '../prompt.js';
4
4
  import { success, error, info, printTable, printJson } from '../output.js';
@@ -270,11 +270,15 @@ async function domain(args, flags) {
270
270
  let target = host;
271
271
  if (!target) {
272
272
  // No host given — resolve the current one so the user doesn't have to retype it.
273
+ // Only a 404 means "nothing configured"; a network/auth/server error must
274
+ // surface as itself, not be misreported as absent state.
273
275
  try {
274
276
  target = (await sdkAuth.getDomain(config.api_key, orgId)).domain;
275
277
  }
276
- catch {
277
- error('No custom auth domain to delete.');
278
+ catch (e) {
279
+ if (e instanceof MyApiError && e.status === 404)
280
+ error('No custom auth domain to delete.');
281
+ throw e;
278
282
  }
279
283
  }
280
284
  await sdkAuth.deleteDomain(config.api_key, orgId, target);
@@ -162,8 +162,13 @@ export async function usage(flags) {
162
162
  info(`Total: ${res.total_display}`);
163
163
  }
164
164
  export async function topup(amountStr, flags) {
165
- const amount = Math.round(parseFloat(amountStr));
166
- if (!amountStr || isNaN(amount) || amount <= 0) {
165
+ // Money-moving input is validated strictly: whole dollars only, no
166
+ // trailing garbage, no silent rounding ("10.9" must not charge $11).
167
+ if (!amountStr || !/^\d+$/.test(amountStr)) {
168
+ error('Amount must be a positive whole number of dollars (e.g. myapi billing topup 10)');
169
+ }
170
+ const amount = Number(amountStr);
171
+ if (amount <= 0) {
167
172
  error('Amount must be a positive whole number of dollars (e.g. myapi billing topup 10)');
168
173
  }
169
174
  if (!flags.yes && !flags.y && amount >= 50) {
@@ -4,7 +4,7 @@ import { container as sdkContainer } from '@myapihq/sdk';
4
4
  import { requireConfig } from '../config.js';
5
5
  import { success, error, printTable, info, printJson, banner } from '../output.js';
6
6
  import { formatDate, pollJob } from '../utils.js';
7
- import { requireOrg } from '../helpers.js';
7
+ import { requireOrg, confirmDestructive } from '../helpers.js';
8
8
  export const EXPOSES = [
9
9
  'POST /container/orgs/{org_id}/containers',
10
10
  'GET /container/orgs/{org_id}/containers',
@@ -162,11 +162,12 @@ export async function get(id, flags) {
162
162
  }
163
163
  export async function del(id, flags) {
164
164
  const config = requireConfig();
165
- const orgId = requireOrg(flags, config, 'myapi container delete <id> [--org <id>]');
165
+ const orgId = requireOrg(flags, config, 'myapi container delete <id> [--yes] [--org <id>]');
166
166
  if (!id)
167
167
  error('Missing id.\nUsage: myapi container delete <id>');
168
+ await confirmDestructive(flags, `delete container ${id} in org ${orgId}`, 'myapi container delete <id> --yes [--org <id>]');
168
169
  await sdkContainer.deleteContainer(config.api_key, orgId, id);
169
- success(`Deleted container ${id}`);
170
+ success(`Deleted container ${id} (org ${orgId})`);
170
171
  }
171
172
  // _isTarball returns true if the path looks like an already-built tar archive
172
173
  // (so we upload it as-is instead of tarring a directory).
@@ -7,7 +7,7 @@ import * as fs from 'fs';
7
7
  import { database as sdk, MyApiError } from '@myapihq/sdk';
8
8
  import { requireConfig } from '../config.js';
9
9
  import { success, error, info, printTable, printJson } from '../output.js';
10
- import { requireOrg, requireArg } from '../helpers.js';
10
+ import { requireOrg, requireArg, confirmDestructive } from '../helpers.js';
11
11
  export const EXPOSES = [
12
12
  'GET /database/orgs/{org_id}/namespaces',
13
13
  'POST /database/orgs/{org_id}/namespaces',
@@ -97,10 +97,11 @@ async function nsCreate(name, flags) {
97
97
  }
98
98
  async function nsDelete(name, flags) {
99
99
  const config = requireConfig();
100
- const orgId = requireOrg(flags, config, 'myapi database delete-namespace <name> [--org <id>]');
100
+ const orgId = requireOrg(flags, config, 'myapi database delete-namespace <name> [--yes] [--org <id>]');
101
101
  requireArg(name, 'name', 'myapi database delete-namespace <name>');
102
+ await confirmDestructive(flags, `delete namespace "${name}" and ALL its keys in org ${orgId}`, 'myapi database delete-namespace <name> --yes [--org <id>]');
102
103
  await sdk.deleteNamespace(config.api_key, orgId, name);
103
- success(`Namespace "${name}" and all its keys deleted.`);
104
+ success(`Namespace "${name}" and all its keys deleted (org ${orgId}).`);
104
105
  }
105
106
  // ── Keys ─────────────────────────────────────────────────────────────────
106
107
  async function keysList(flags) {
@@ -1,8 +1,9 @@
1
1
  import { domain as sdkDomain, email as sdkEmail } from '@myapihq/sdk';
2
2
  import { requireConfig } from '../config.js';
3
3
  import { success, error, info, printTable, printJson } from '../output.js';
4
- import { formatDate } from '../utils.js';
4
+ import { formatDate, retryFunds } from '../utils.js';
5
5
  import { resolveRegistrantForRegister } from '../registrant.js';
6
+ import { confirm, isNonInteractive } from '../prompt.js';
6
7
  import { requireOrg, requireDomain } from '../helpers.js';
7
8
  export const EXPOSES = [
8
9
  'GET /domain/orgs/{org_id}/list',
@@ -63,6 +64,8 @@ export const SCHEMA = {
63
64
  subdomain: 'string',
64
65
  // Assign: opt out of binding the www Worker route alongside apex.
65
66
  'no-www': 'boolean',
67
+ // Assign: override the reassignment pre-flight guard.
68
+ force: 'boolean',
66
69
  };
67
70
  export async function check(domainArg, flags) {
68
71
  const config = requireConfig();
@@ -97,7 +100,7 @@ export async function register(domainArg, flags) {
97
100
  // Required by ICANN; resolver throws a useful error if nothing's set up
98
101
  // in non-interactive contexts, or runs an interactive prompt for humans.
99
102
  const registrant = await resolveRegistrantForRegister(flags, config);
100
- await sdkDomain.registerDomain(config.api_key, orgId, domainArg, { years, registrant });
103
+ await retryFunds(() => sdkDomain.registerDomain(config.api_key, orgId, domainArg, { years, registrant }));
101
104
  success(`Registered ${domainArg}!`);
102
105
  info(`Assign it to your org now:\n myapi domain assign ${domainArg} --org ${orgId}`);
103
106
  info(`DNS propagation can take a few minutes — track it with: myapi domain status ${domainArg}`);
@@ -166,7 +169,15 @@ export async function renew(domainArg, flags) {
166
169
  const orgId = requireOrg(flags, config, 'myapi domain renew <domain> [--yes] [--org <id>]');
167
170
  if (!domainArg)
168
171
  error('Missing required arguments.\nUsage: myapi domain renew <domain> [--yes] [--org <id>]');
169
- await sdkDomain.renewDomain(config.api_key, orgId, domainArg);
172
+ if (!flags.yes) {
173
+ if (isNonInteractive()) {
174
+ error(`Refusing to renew ${domainArg} without --yes — renewal charges org ${orgId}'s balance immediately.\nUsage: myapi domain renew <domain> --yes [--org <id>]`);
175
+ }
176
+ const ok = await confirm(`› Renew ${domainArg} now? This charges org ${orgId}'s balance. (y/N) `, false);
177
+ if (!ok)
178
+ error('Aborted.');
179
+ }
180
+ await retryFunds(() => sdkDomain.renewDomain(config.api_key, orgId, domainArg));
170
181
  success(`Renewed ${domainArg}.`);
171
182
  info(`Track expiry with: myapi domain status ${domainArg}`);
172
183
  }
@@ -196,6 +207,23 @@ export async function assign(domainArg, flags) {
196
207
  const orgId = requireOrg(flags, config, 'myapi domain assign <domain> [--no-www] [--org <id>]');
197
208
  if (!domainArg)
198
209
  error('Missing required arguments.\nUsage: myapi domain assign <domain> [--no-www] [--org <id>]');
210
+ // Assign is also the reassign path: if the domain is currently bound to a
211
+ // *different* org, a bare assign silently moves it (this has bitten us in
212
+ // production). Pre-flight the current binding and require --force to move.
213
+ // A failed pre-read aborts — never downgrade a safety check to a guess.
214
+ if (!flags.force) {
215
+ let current;
216
+ try {
217
+ const domains = await sdkDomain.listDomains(config.api_key, orgId, 'all');
218
+ current = domains.find(d => d.domain === domainArg);
219
+ }
220
+ catch (e) {
221
+ error(`Could not verify ${domainArg}'s current assignment (${e instanceof Error ? e.message : e}).\nRefusing to assign blind — retry, or pass --force to skip this check.`);
222
+ }
223
+ if (current?.org_id && current.org_id !== orgId) {
224
+ error(`${domainArg} is currently assigned to org ${current.org_id}.\nAssigning it to org ${orgId} would MOVE it. Re-run with --force to reassign.`);
225
+ }
226
+ }
199
227
  const includeWww = flags['no-www'] ? false : undefined; // omitted → backend default (true).
200
228
  const res = await sdkDomain.assignDomain(config.api_key, orgId, domainArg, { includeWww });
201
229
  if (flags.json) {
@@ -398,10 +426,6 @@ async function recordsGet(domainArg, recordId, flags) {
398
426
  if (!recordId)
399
427
  error('Missing record id.\nUsage: myapi domain records get <domain> <record-id>');
400
428
  const r = await sdkDomain.getDnsRecord(config.api_key, orgId, domain, recordId);
401
- if (flags.json) {
402
- printJson(r);
403
- return;
404
- }
405
429
  printJson(r);
406
430
  }
407
431
  async function recordsCreate(domainArg, flags) {
@@ -564,14 +588,15 @@ After running:
564
588
  2. At your current registrar, change the nameservers to the values returned.
565
589
  3. Wait for propagation. Use "myapi domain status <domain> --watch" to track
566
590
  activation — the backend live-checks Cloudflare each time you poll.`,
567
- 'renew': `myapi domain renew <domain> [--org <id>]
591
+ 'renew': `myapi domain renew <domain> [--yes] [--org <id>]
568
592
 
569
593
  Renews a registered domain for one more registration period (typically 1 year).
570
594
  Charged against the org's billing balance — confirm with "myapi billing balance"
571
- before running.
595
+ before running. Asks for confirmation; pass --yes to skip (required in
596
+ non-interactive runs).
572
597
 
573
598
  Track expiry afterward with: myapi domain status <domain>`,
574
- 'assign': `myapi domain assign <domain> [--no-www] [--org <id>]
599
+ 'assign': `myapi domain assign <domain> [--no-www] [--force] [--org <id>]
575
600
 
576
601
  Assigns a registered or imported domain to an org. Backend binds Worker
577
602
  routes for both <domain>/* and www.<domain>/* by default; www returns a 301
@@ -580,11 +605,11 @@ at https://<domain> after DNS propagation completes.
580
605
 
581
606
  --no-www Skip the www route binding. The www form will not resolve at the
582
607
  edge unless you create your own DNS + Worker setup for it.
608
+ --force Skip the reassignment pre-flight guard.
583
609
 
584
- WARNING: This is also the reassign path. If you previously assigned <domain>
585
- to a different org, re-running assign moves it. Pre-flight with:
586
- myapi domain list --filter all
587
- to confirm the current binding before reassigning.`,
610
+ WARNING: This is also the reassign path. If the domain is currently assigned
611
+ to a different org, assign refuses unless you pass --force. Check the current
612
+ binding with: myapi domain list --filter all`,
588
613
  'unassign': 'myapi domain unassign <domain> [--org <id>]',
589
614
  'status': `myapi domain status <domain> [--org <id>] [--watch] [--json]
590
615
 
@@ -1,6 +1,7 @@
1
1
  import { email as sdkEmail } from '@myapihq/sdk';
2
2
  import { requireConfig } from '../../config.js';
3
3
  import { success, error, printTable, printJson, info } from '../../output.js';
4
+ import { retryFunds } from '../../utils.js';
4
5
  export const EXPOSES = [
5
6
  'POST /email/send',
6
7
  'GET /email/status/{message_id}',
@@ -22,7 +23,9 @@ function summarizeMessage(m) {
22
23
  }
23
24
  async function send(flags) {
24
25
  const config = requireConfig();
25
- if (!flags.from || !flags.to || !flags.subject) {
26
+ // typeof check (not truthiness): a bare `--to` whose value got swallowed by
27
+ // the next flag parses as `true`, which must fail here — not reach the API.
28
+ if (typeof flags.from !== 'string' || typeof flags.to !== 'string' || typeof flags.subject !== 'string') {
26
29
  error('Missing required arguments.\nUsage: myapi email message send --from <email> --to <email> --subject <str> [--body <str> | --html <str> | --template-id <id> [--template-vars <json>]]');
27
30
  }
28
31
  if (!flags.body && !flags.html && !flags['template-id'])
@@ -38,7 +41,7 @@ async function send(flags) {
38
41
  error('--template-vars must be valid JSON');
39
42
  }
40
43
  }
41
- const res = await sdkEmail.sendEmail(config.api_key, {
44
+ const res = await retryFunds(() => sdkEmail.sendEmail(config.api_key, {
42
45
  from: flags.from,
43
46
  to: [flags.to],
44
47
  subject: flags.subject,
@@ -46,7 +49,7 @@ async function send(flags) {
46
49
  html: flags.html,
47
50
  template_id: flags['template-id'],
48
51
  template_vars: templateVars,
49
- });
52
+ }));
50
53
  success(`Email sent! Message ID: ${res.message_id}`);
51
54
  }
52
55
  async function status(messageId, _flags) {
@@ -1,7 +1,7 @@
1
1
  import { email as sdkEmail } from '@myapihq/sdk';
2
2
  import { requireConfig } from '../../config.js';
3
3
  import { success, error, printTable, printJson, info } from '../../output.js';
4
- import { pollJob } from '../../utils.js';
4
+ import { pollJob, retryFunds } from '../../utils.js';
5
5
  import { requireOrg } from '../../helpers.js';
6
6
  export const EXPOSES = [
7
7
  'POST /email/orgs/{org_id}/templates/generate',
@@ -20,10 +20,10 @@ async function generate(nameArg, flags) {
20
20
  if (!name || !flags.prompt) {
21
21
  error('Missing required arguments.\nUsage: myapi email template generate <name> --prompt <str> [--org <id>]\n or: myapi email template generate --name <name> --prompt <str> [--org <id>]');
22
22
  }
23
- const job = await sdkEmail.generateTemplate(config.api_key, orgId, {
23
+ const job = await retryFunds(() => sdkEmail.generateTemplate(config.api_key, orgId, {
24
24
  prompt: flags.prompt,
25
25
  name,
26
- });
26
+ }));
27
27
  const status = await pollJob({
28
28
  label: 'Generating template',
29
29
  timeoutMs: 90_000,
@@ -3,7 +3,7 @@ import { fn as sdkFn } from '@myapihq/sdk';
3
3
  import { requireConfig } from '../config.js';
4
4
  import { success, error, printTable, info, printJson, banner } from '../output.js';
5
5
  import { formatDate } from '../utils.js';
6
- import { requireOrg } from '../helpers.js';
6
+ import { requireOrg, confirmDestructive } from '../helpers.js';
7
7
  export const EXPOSES = [
8
8
  'POST /function/orgs/{org_id}/functions',
9
9
  'GET /function/orgs/{org_id}/functions',
@@ -126,11 +126,12 @@ export async function get(id, flags) {
126
126
  }
127
127
  export async function del(id, flags) {
128
128
  const config = requireConfig();
129
- const orgId = requireOrg(flags, config, 'myapi fn delete <id> [--org <id>]');
129
+ const orgId = requireOrg(flags, config, 'myapi fn delete <id> [--yes] [--org <id>]');
130
130
  if (!id)
131
131
  error('Missing id.\nUsage: myapi fn delete <id>');
132
+ await confirmDestructive(flags, `delete function ${id} in org ${orgId}`, 'myapi fn delete <id> --yes [--org <id>]');
132
133
  await sdkFn.deleteFunction(config.api_key, orgId, id);
133
- success(`Deleted function ${id}`);
134
+ success(`Deleted function ${id} (org ${orgId})`);
134
135
  }
135
136
  // deploy uploads a single-file JS bundle. The backend wraps it with the
136
137
  // MYAPI shim and ships it to Cloudflare Workers. The scoped API key is
@@ -4,7 +4,7 @@ import { funnel as sdkFunnel, hq } from '@myapihq/sdk';
4
4
  import { requireConfig } from '../config.js';
5
5
  import { success, error, printTable, info, printJson } from '../output.js';
6
6
  import { formatDate } from '../utils.js';
7
- import { requireOrg } from '../helpers.js';
7
+ import { requireOrg, confirmDestructive } from '../helpers.js';
8
8
  export const EXPOSES = [
9
9
  'POST /funnel/orgs/{org_id}/funnels',
10
10
  'GET /funnel/orgs/{org_id}/funnels',
@@ -77,7 +77,7 @@ export async function list(flags) {
77
77
  }
78
78
  const rows = funnels.map(f => ({
79
79
  id: f.id,
80
- preview_url: f.subdomain_url || '',
80
+ subdomain_url: f.subdomain_url || '',
81
81
  created_at: formatDate(f.created_at),
82
82
  updated_at: formatDate(f.updated_at),
83
83
  }));
@@ -128,11 +128,12 @@ export async function get(id, flags) {
128
128
  }
129
129
  export async function del(id, flags) {
130
130
  const config = requireConfig();
131
- const orgId = requireOrg(flags, config, 'myapi funnel delete <id> [--org <id>]');
131
+ const orgId = requireOrg(flags, config, 'myapi funnel delete <id> [--yes] [--org <id>]');
132
132
  if (!id)
133
- error('Missing required arguments.\nUsage: myapi funnel delete <id> [--org <id>]');
133
+ error('Missing required arguments.\nUsage: myapi funnel delete <id> [--yes] [--org <id>]');
134
+ await confirmDestructive(flags, `delete funnel ${id} (and all its live pages) in org ${orgId}`, 'myapi funnel delete <id> --yes [--org <id>]');
134
135
  await sdkFunnel.deleteFunnel(config.api_key, orgId, id);
135
- success(`Funnel ${id} deleted`);
136
+ success(`Funnel ${id} deleted (org ${orgId})`);
136
137
  }
137
138
  // List the pages currently published to a funnel. Resolves the funnel id
138
139
  // from positional arg, --funnel flag, or the user's default funnel.
@@ -206,19 +207,34 @@ This funnel already serves a page at ${finalSlug}; pushing would replace it.
206
207
  error("No content provided via stdin. Usage: echo '<h1>Hello</h1>' | myapi funnel push [slug]");
207
208
  const result = await sdkFunnel.pushFunnelPage(config.api_key, orgId, funnelId, { slug: finalSlug, html });
208
209
  const { orgLabel, funnelLabel } = await describeTarget(config.api_key, orgId, funnelId);
210
+ let liveUrl = result?.url;
211
+ if (!liveUrl) {
212
+ // Best-effort URL enrichment — the push already succeeded, so a failed
213
+ // org lookup must not turn a successful write into a non-zero exit.
214
+ const org = await hq.getOrg(config.api_key, orgId).catch(() => null);
215
+ if (org?.preview_subdomain) {
216
+ liveUrl = `https://${org.preview_subdomain}.makeautonomous.com${finalSlug}`;
217
+ }
218
+ }
219
+ // Machine output: the live URL is carried as `subdomain_url` — the same field
220
+ // name `funnel get`/`list`/`create` use — so an agent that pushes has the URL
221
+ // in hand without a follow-up `funnel get` (and without guessing the field).
222
+ if (flags.json) {
223
+ printJson({
224
+ slug: finalSlug,
225
+ subdomain_url: liveUrl ?? null,
226
+ overwritten: Boolean(clash),
227
+ org_id: orgId,
228
+ funnel_id: funnelId,
229
+ });
230
+ return;
231
+ }
209
232
  success(`Pushed ${clash ? '(overwrote) ' : ''}page to ${finalSlug}`);
210
233
  info(`Org: ${orgLabel}`);
211
234
  info(`Funnel: ${funnelLabel}`);
212
235
  if (resolvedFromOrg) {
213
236
  info(`(Auto-picked the org's only funnel. Pin it explicitly with --funnel ${funnelId} or: myapi config set-funnel ${funnelId})`);
214
237
  }
215
- let liveUrl = result?.url;
216
- if (!liveUrl) {
217
- const org = await hq.getOrg(config.api_key, orgId);
218
- if (org.preview_subdomain) {
219
- liveUrl = `https://${org.preview_subdomain}.makeautonomous.com${finalSlug}`;
220
- }
221
- }
222
238
  if (liveUrl)
223
239
  info(`Preview: ${liveUrl}`);
224
240
  // If we're still on the preview subdomain, hint at how to serve on a
@@ -563,7 +579,7 @@ Examples:
563
579
  myapi funnel publish ./dist # prod — refused if a site exists
564
580
  myapi funnel publish ./dist --force # prod — replace existing site
565
581
  myapi funnel publish ./site --api-fn <function_id>`,
566
- 'push': `myapi funnel push [slug] [--funnel <id>] [--slug <path>] [--force] [--org <id>] < page.html
582
+ 'push': `myapi funnel push [slug] [--funnel <id>] [--slug <path>] [--force] [--org <id>] [--json] < page.html
567
583
 
568
584
  Reads HTML from stdin and publishes to <slug> on your funnel. Funnel is resolved
569
585
  from --funnel, the default funnel, or (only if the org has exactly one) auto-picked.
@@ -573,6 +589,10 @@ exists there, push refuses and names the org + funnel + slug at risk; re-run
573
589
  with --force to replace it. On success it prints the resolved org + funnel so
574
590
  you can confirm you wrote to the namespace you intended.
575
591
 
592
+ With --json, prints { slug, subdomain_url, overwritten, org_id, funnel_id } —
593
+ the live URL is carried as subdomain_url (same field name as funnel get/list),
594
+ so you don't need a follow-up "funnel get" to find where the page went.
595
+
576
596
  By default, your funnel is served on a preview subdomain (*.makeautonomous.com).
577
597
  To serve on your own domain, register and assign one with:
578
598
  myapi domain register <domain>
@@ -1,8 +1,10 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
1
3
  import { git as sdkGit } from '@myapihq/sdk';
2
4
  import { requireConfig } from '../config.js';
3
5
  import { success, error, printTable, info, printJson } from '../output.js';
4
- import { formatDate } from '../utils.js';
5
- import { requireOrg, requireArg } from '../helpers.js';
6
+ import { formatDate, retryFunds } from '../utils.js';
7
+ import { requireOrg, requireArg, confirmDestructive } from '../helpers.js';
6
8
  export const EXPOSES = [
7
9
  'POST /git/orgs/{org_id}/repos',
8
10
  'GET /git/orgs/{org_id}/repos',
@@ -35,11 +37,62 @@ export const SCHEMA = {
35
37
  'author-name': 'string',
36
38
  'author-email': 'string',
37
39
  changes: 'string',
40
+ dir: 'string',
41
+ file: 'string',
42
+ as: 'string',
38
43
  target: 'string',
39
44
  source: 'string',
40
45
  };
41
46
  const shortSha = (s) => (s || '').slice(0, 8);
42
47
  const firstLine = (s) => (s || '').split('\n')[0];
48
+ // Build a commit's file changes from local disk (--dir / --file), falling back
49
+ // to an explicit --changes JSON array. This is the ergonomic path an agent
50
+ // actually needs: point at a directory of generated code, not hand-author JSON.
51
+ function collectChanges(flags) {
52
+ const changes = [];
53
+ const toChange = (abs, repoPath) => {
54
+ const buf = fs.readFileSync(abs);
55
+ // Binary files (null byte present) go base64; everything else UTF-8 text.
56
+ return buf.includes(0)
57
+ ? { path: repoPath, content_base64: buf.toString('base64') }
58
+ : { path: repoPath, content: buf.toString('utf-8') };
59
+ };
60
+ if (typeof flags.dir === 'string' && flags.dir) {
61
+ const root = flags.dir;
62
+ if (!fs.existsSync(root) || !fs.statSync(root).isDirectory())
63
+ error(`--dir: not a directory: ${root}`);
64
+ const walk = (d) => {
65
+ for (const ent of fs.readdirSync(d, { withFileTypes: true })) {
66
+ if (ent.name === '.git' || ent.name === 'node_modules')
67
+ continue;
68
+ const abs = path.join(d, ent.name);
69
+ if (ent.isDirectory())
70
+ walk(abs);
71
+ else if (ent.isFile())
72
+ changes.push(toChange(abs, path.relative(root, abs).split(path.sep).join('/')));
73
+ }
74
+ };
75
+ walk(root);
76
+ }
77
+ if (typeof flags.file === 'string' && flags.file) {
78
+ if (!fs.existsSync(flags.file))
79
+ error(`--file: not found: ${flags.file}`);
80
+ const repoPath = (typeof flags.as === 'string' && flags.as) ? flags.as : path.basename(flags.file);
81
+ changes.push(toChange(flags.file, repoPath));
82
+ }
83
+ if (typeof flags.changes === 'string' && flags.changes) {
84
+ try {
85
+ const parsed = JSON.parse(flags.changes);
86
+ if (!Array.isArray(parsed))
87
+ throw new Error('not an array');
88
+ changes.push(...parsed);
89
+ }
90
+ catch (e) {
91
+ error(`--changes is not valid JSON: ${e?.message ?? e}`);
92
+ }
93
+ }
94
+ return changes;
95
+ }
43
96
  // ── Repos ────────────────────────────────────────────────────────────────────
44
97
  export async function create(name, flags) {
45
98
  const config = requireConfig();
@@ -79,10 +132,11 @@ export async function get(repo, flags) {
79
132
  }
80
133
  export async function del(repo, flags) {
81
134
  const config = requireConfig();
82
- const orgId = requireOrg(flags, config, 'myapi git delete <repo> [--org <id>]');
135
+ const orgId = requireOrg(flags, config, 'myapi git delete <repo> [--yes] [--org <id>]');
83
136
  requireArg(repo, 'repo', 'myapi git delete <repo>');
137
+ await confirmDestructive(flags, `delete repository "${repo}" in org ${orgId}`, 'myapi git delete <repo> --yes [--org <id>]');
84
138
  await sdkGit.deleteRepo(config.api_key, orgId, repo);
85
- success(`Deleted repository ${repo}`);
139
+ success(`Deleted repository ${repo} (org ${orgId})`);
86
140
  }
87
141
  // ── Refs / history ───────────────────────────────────────────────────────────
88
142
  export async function refs(repo, flags) {
@@ -94,12 +148,15 @@ export async function refs(repo, flags) {
94
148
  printJson(r);
95
149
  return;
96
150
  }
97
- info(`HEAD: ${r.head}`);
98
- info(`Branches (${r.branches.length}):`);
99
- for (const b of r.branches)
151
+ // branches/tags come back null (not []) on a repo with none — guard both.
152
+ const branches = r.branches ?? [];
153
+ const tags = r.tags ?? [];
154
+ info(`HEAD: ${r.head ?? '(none)'}`);
155
+ info(`Branches (${branches.length}):`);
156
+ for (const b of branches)
100
157
  info(` ${b.name} ${shortSha(b.sha)}`);
101
- info(`Tags (${r.tags.length}):`);
102
- for (const t of r.tags)
158
+ info(`Tags (${tags.length}):`);
159
+ for (const t of tags)
103
160
  info(` ${t.name} ${shortSha(t.sha)}`);
104
161
  }
105
162
  export async function log(repo, flags) {
@@ -188,37 +245,45 @@ export async function diff(repo, flags) {
188
245
  // ── Writes ───────────────────────────────────────────────────────────────────
189
246
  export async function commit(repo, flags) {
190
247
  const config = requireConfig();
191
- const orgId = requireOrg(flags, config, 'myapi git commit <repo> --branch <b> --message <m> --changes <json> [--org <id>]');
192
- requireArg(repo, 'repo', 'myapi git commit <repo> --branch <b> --message <m> --changes <json>');
248
+ const usage = 'myapi git commit <repo> --branch <b> --message <m> (--dir <path> | --file <path> [--as <repo-path>] | --changes <json>)';
249
+ const orgId = requireOrg(flags, config, usage);
250
+ requireArg(repo, 'repo', usage);
193
251
  const branch = flags.branch;
194
252
  if (!branch)
195
- error('Missing --branch.\nUsage: myapi git commit <repo> --branch <b> --message <m> --changes <json>');
196
- if (typeof flags.changes !== 'string') {
197
- error('Missing --changes.\n--changes is a JSON array of file edits, e.g.\n --changes \'[{"path":"README.md","content":"# Hello"}]\'\nEach entry: {path, content | content_base64 | delete:true, mode?}.');
198
- }
199
- let changes;
200
- try {
201
- changes = JSON.parse(flags.changes);
202
- if (!Array.isArray(changes))
203
- throw new Error('not an array');
204
- }
205
- catch (e) {
206
- error(`--changes is not valid JSON: ${e?.message ?? e}`);
253
+ error(`Missing --branch.\nUsage: ${usage}`);
254
+ const changes = collectChanges(flags);
255
+ if (changes.length === 0) {
256
+ error(`Nothing to commit. Provide one of:\n --dir <path> commit every file under a local directory\n --file <path> commit a single local file (--as <repo-path> to rename)\n --changes <json> explicit edits: [{"path":"README.md","content":"# Hello"}]`);
207
257
  }
208
258
  const payload = {
209
- branch,
259
+ branch: branch,
210
260
  message: flags.message || '',
211
- changes: changes,
261
+ changes,
212
262
  };
213
- if (typeof flags.base === 'string')
263
+ // Base resolution (optimistic concurrency). Explicit --base wins. Otherwise
264
+ // auto-resolve so the command "just works": if the branch exists, target its
265
+ // current tip; if it doesn't, send "" (create). Without this an agent would
266
+ // have to fetch the tip SHA itself, and a second commit fails REF_CONFLICT.
267
+ if (typeof flags.base === 'string') {
214
268
  payload.base = flags.base;
269
+ }
270
+ else {
271
+ // A failed ref read must abort — swallowing it would be indistinguishable
272
+ // from "branch doesn't exist" and silently switch the commit to
273
+ // create-semantics (base "") against an existing branch.
274
+ const refs = await sdkGit.listRefs(config.api_key, orgId, repo).catch((e) => {
275
+ error(`Could not read refs for "${repo}" (${e instanceof Error ? e.message : String(e)}).\nRefusing to guess the commit base — retry, or pass --base <sha> explicitly.`);
276
+ });
277
+ const tip = refs.branches?.find(b => b.name === branch)?.sha;
278
+ payload.base = tip ?? '';
279
+ }
215
280
  if (flags['author-name'] || flags['author-email']) {
216
281
  payload.author = {
217
282
  name: flags['author-name'] || '',
218
283
  email: flags['author-email'] || '',
219
284
  };
220
285
  }
221
- const res = await sdkGit.commit(config.api_key, orgId, repo, payload);
286
+ const res = await retryFunds(() => sdkGit.commit(config.api_key, orgId, repo, payload));
222
287
  if (flags.json) {
223
288
  printJson(res);
224
289
  return;
@@ -291,15 +356,23 @@ const SUBCOMMAND_USAGE = {
291
356
  'tree': 'myapi git tree <repo> <ref> [--path <p>] [--org <id>] [--json]',
292
357
  'blob': 'myapi git blob <repo> <ref> <path> [--org <id>]\n\nPrints the file content (decoded). --json gives {path, size, content_base64}.',
293
358
  'diff': 'myapi git diff <repo> --base <ref> --head <ref> [--org <id>]',
294
- 'commit': `myapi git commit <repo> --branch <b> --message <m> --changes <json> [--base <ref>] [--author-name <n>] [--author-email <e>] [--org <id>]
359
+ 'commit': `myapi git commit <repo> --branch <b> --message <m> (--dir <path> | --file <path> [--as <repo-path>] | --changes <json>) [--base <ref>] [--author-name <n>] [--author-email <e>] [--org <id>]
360
+
361
+ Source the files to commit from local disk or explicit JSON (combinable):
362
+ --dir <path> commit every file under a local directory (recursive;
363
+ skips .git/ and node_modules/; binary files auto-base64)
364
+ --file <path> commit a single local file (--as <repo-path> to rename it
365
+ in the repo; defaults to the file's basename)
366
+ --changes <json> explicit edits — array of
367
+ {path, content | content_base64 | delete:true, mode?}
295
368
 
296
- --changes is a JSON array of file edits — each entry is
297
- {path, content | content_base64 | delete:true, mode?}. Example:
298
- myapi git commit my-repo --branch main --message "init" \\
299
- --changes '[{"path":"README.md","content":"# Hello"}]'
369
+ Examples:
370
+ myapi git commit site --branch main --message "init" --dir ./public
371
+ myapi git commit site --branch main --message "add page" --file ./out.html --as index.html
372
+ myapi git commit site --branch main --message "rm" --changes '[{"path":"old.txt","delete":true}]'
300
373
 
301
- --base is the expected current branch tip (optimistic concurrency);
302
- omit it to create the branch, or pass it to guard against a stale write.`,
374
+ --base is the expected current branch tip (optimistic concurrency); omit it to
375
+ create the branch or write onto its current tip, or pass it to guard a stale write.`,
303
376
  'create-branch': 'myapi git create-branch <repo> <name> --from <ref> [--org <id>]',
304
377
  'delete-branch': 'myapi git delete-branch <repo> <branch> [--org <id>]',
305
378
  'tag': 'myapi git tag <repo> <name> --ref <ref> [--org <id>]',
@@ -327,7 +400,7 @@ History & content:
327
400
  refs <repo> List branches and tags
328
401
 
329
402
  Writes:
330
- commit <repo> Commit file changes (--branch, --message, --changes)
403
+ commit <repo> Commit files (--branch, --message, --dir | --file | --changes)
331
404
  create-branch <repo> <name> Create a branch (--from <ref>)
332
405
  delete-branch <repo> <branch> Delete a branch
333
406
  tag <repo> <name> Create a tag (--ref <ref>)