@myapihq/cli 1.1.0-wip.2 → 1.1.0-wip.4

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.
@@ -108,13 +108,27 @@ export async function push(slug, flags) {
108
108
  if (resolvedFromOrg) {
109
109
  info(`(Used the org's only funnel: ${funnelId}. Set a default with: myapi config set-funnel ${funnelId})`);
110
110
  }
111
- if (result?.url) {
112
- info(`Preview: ${result.url}`);
113
- }
114
- else {
111
+ let liveUrl = result?.url;
112
+ if (!liveUrl) {
115
113
  const org = await hq.getOrg(config.api_key, orgId);
116
114
  if (org.preview_subdomain) {
117
- info(`Preview: https://${org.preview_subdomain}.makeautonomous.com${finalSlug}`);
115
+ liveUrl = `https://${org.preview_subdomain}.makeautonomous.com${finalSlug}`;
116
+ }
117
+ }
118
+ if (liveUrl)
119
+ info(`Preview: ${liveUrl}`);
120
+ // If we're still on the preview subdomain, hint at how to serve on a
121
+ // custom domain. Tailor the hint: if a default_domain is already
122
+ // configured (registered + meant to be used), suggest assigning it
123
+ // directly; otherwise point at register + assign.
124
+ const onPreview = liveUrl?.includes('.makeautonomous.com');
125
+ if (onPreview) {
126
+ const dom = config.default_domain;
127
+ if (dom) {
128
+ info(`(Serving on the preview subdomain. To serve on ${dom} instead: myapi domain assign ${dom})`);
129
+ }
130
+ else {
131
+ info(`(Serving on the preview subdomain. To serve on a custom domain: myapi domain register <domain> && myapi domain assign <domain>)`);
118
132
  }
119
133
  }
120
134
  }
@@ -149,6 +163,11 @@ const SUBCOMMAND_USAGE = {
149
163
  Reads HTML from stdin and publishes to <slug> on your funnel. Funnel is resolved
150
164
  from --funnel, the default funnel, or (only if the org has exactly one) auto-picked.
151
165
 
166
+ By default, your funnel is served on a preview subdomain (*.makeautonomous.com).
167
+ To serve on your own domain, register and assign one with:
168
+ myapi domain register <domain>
169
+ myapi domain assign <domain>
170
+
152
171
  Examples:
153
172
  echo '<h1>Hello</h1>' | myapi funnel push /
154
173
  cat about.html | myapi funnel push /about
@@ -1,4 +1,4 @@
1
- export declare function generate(flags: Record<string, string | boolean>): Promise<void>;
2
- export declare function list(flags: Record<string, string | boolean>): Promise<void>;
3
- export declare function del(id: string, flags: Record<string, string | boolean>): Promise<void>;
4
- export declare function run(subcommand: string | undefined, args: string[], flags: Record<string, string | boolean>): Promise<void>;
1
+ import type { FlagSchema } from '../flags.js';
2
+ import { type Flags } from '../helpers.js';
3
+ export declare const SCHEMA: FlagSchema;
4
+ export declare function run(subcommand: string | undefined, args: string[], flags: Flags): Promise<void>;
@@ -1,70 +1,157 @@
1
1
  import { image as sdkImage } from '@myapihq/sdk';
2
2
  import { requireConfig } from '../config.js';
3
- import { success, error, printTable, info, printJson } from '../output.js';
4
- import { sleep } from '../utils.js';
5
- export async function generate(flags) {
3
+ import { success, error, printTable, info, printJson, banner } from '../output.js';
4
+ import { formatDate, pollJob } from '../utils.js';
5
+ import { requireOrg, requireArg } from '../helpers.js';
6
+ export const SCHEMA = {
7
+ org: 'string',
8
+ prompt: 'string',
9
+ ratio: 'string',
10
+ style: 'string',
11
+ colors: 'string',
12
+ text: 'boolean',
13
+ };
14
+ const VALID_RATIOS = new Set(['1:1', '16:9', '9:16', '4:3', '3:4']);
15
+ // Hex list: optional leading #, 3 or 6 hex digits, comma-separated.
16
+ const HEX_LIST_RE = /^#?[0-9a-fA-F]{3}(?:[0-9a-fA-F]{3})?(?:\s*,\s*#?[0-9a-fA-F]{3}(?:[0-9a-fA-F]{3})?)*$/;
17
+ const PRICE_USD = 0.05;
18
+ function summarizeImage(j) {
19
+ const prompt = (j.prompt || '');
20
+ const truncated = prompt.length > 60 ? prompt.slice(0, 60) + '...' : prompt;
21
+ return {
22
+ job_id: j.job_id,
23
+ status: j.status,
24
+ aspect_ratio: j.aspect_ratio,
25
+ prompt: truncated,
26
+ url: j.url || '',
27
+ created_at: j.created_at ? formatDate(j.created_at) : '',
28
+ };
29
+ }
30
+ async function generate(promptArg, flags) {
6
31
  const config = requireConfig();
7
- const orgId = flags.org || config.default_org;
8
- if (!orgId || !flags.prompt) {
9
- error("Missing required arguments.\nUsage: myapi image generate --prompt <text> --org <id>\n(Or set defaults via: myapi config set-org <id>)");
32
+ const orgId = requireOrg(flags, config, 'myapi image generate <prompt> [--ratio 1:1|16:9|9:16|4:3|3:4] [--style <s>] [--colors <hex,hex>] [--text] [--org <id>]');
33
+ // Convention: first required arg is positional. --prompt still works for back-compat.
34
+ const prompt = promptArg || flags.prompt;
35
+ if (!prompt) {
36
+ error('Missing required arguments.\nUsage: myapi image generate <prompt> [--ratio <ratio>] [--style <s>] [--colors <c>] [--text] [--org <id>]\n or: myapi image generate --prompt "<text>" ...');
37
+ }
38
+ // Client-side validation: catch typos before they round-trip.
39
+ if (flags.ratio && !VALID_RATIOS.has(flags.ratio)) {
40
+ error(`Invalid --ratio "${flags.ratio}". Allowed: ${[...VALID_RATIOS].join(', ')}`);
10
41
  }
11
- const payload = { prompt: flags.prompt };
42
+ if (flags.colors && !HEX_LIST_RE.test(flags.colors)) {
43
+ error(`Invalid --colors "${flags.colors}". Expected comma-separated hex like "#ff6600,#003366" (3- or 6-digit, # optional).`);
44
+ }
45
+ const payload = { prompt };
12
46
  if (flags.ratio)
13
47
  payload.aspect_ratio = flags.ratio;
14
48
  if (flags.style)
15
49
  payload.style = flags.style;
16
50
  if (flags.colors)
17
51
  payload.colors = flags.colors;
18
- const res = await sdkImage.generateImage(config.api_key, orgId, payload);
19
- process.stdout.write("Generating image ");
20
- const chars = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
21
- let i = 0;
22
- let elapsed = 0;
23
- while (elapsed < 60000) {
24
- const job = await sdkImage.getImageJob(config.api_key, orgId, res.job_id);
25
- if (job.status === 'completed') {
26
- process.stdout.write('\r\x1b[K');
27
- if (flags.json)
28
- printJson(job);
29
- else
30
- success(`Image generated!\nURL: ${job.url}`);
31
- return;
32
- }
33
- if (job.status === 'failed') {
34
- process.stdout.write('\r\x1b[K');
35
- error(`Image generation failed: ${job.error || 'Unknown error'}`);
36
- }
37
- process.stdout.write(`\rGenerating image ${chars[i++ % chars.length]}`);
38
- await sleep(3000);
39
- elapsed += 3000;
52
+ if (flags.text)
53
+ payload.has_text = true;
54
+ // Cost preview before kickoff. Goes to stderr (banner) so --json output
55
+ // stays clean for piping.
56
+ banner(`› Charging $${PRICE_USD.toFixed(2)} for this image generation…`);
57
+ const job = await sdkImage.generateImage(config.api_key, orgId, payload);
58
+ const final = await pollJob({
59
+ label: 'Generating image',
60
+ timeoutMs: 90_000,
61
+ intervalMs: 3000,
62
+ check: () => sdkImage.getImageJob(config.api_key, orgId, job.job_id),
63
+ isDone: s => s.status === 'completed',
64
+ isFailed: s => s.status === 'failed',
65
+ failedMessage: 'Image generation failed',
66
+ timeoutMessage: 'Image generation timed out — re-run "myapi image get <id>" to check later.',
67
+ });
68
+ if (flags.json) {
69
+ printJson(final);
70
+ return;
40
71
  }
41
- process.stdout.write('\r\x1b[K');
42
- error("Image generation timed out");
72
+ success(`Image generated!\nID: ${final.job_id}\nURL: ${final.url}`);
43
73
  }
44
- export async function list(flags) {
74
+ async function list(flags) {
45
75
  const config = requireConfig();
46
- const orgId = flags.org || config.default_org;
47
- if (!orgId)
48
- error("Missing required arguments.\nUsage: myapi image list --org <id>\n(Or set defaults via: myapi config set-org <id>)");
76
+ const orgId = requireOrg(flags, config, 'myapi image list [--org <id>]');
49
77
  const images = await sdkImage.listImages(config.api_key, orgId);
50
- if (flags.json)
78
+ if (flags.json) {
51
79
  printJson(images);
52
- else
53
- printTable(images);
80
+ return;
81
+ }
82
+ printTable(images.map(summarizeImage), {
83
+ flags,
84
+ empty: 'No images yet. Generate one: myapi image generate "<prompt>"',
85
+ });
54
86
  }
55
- export async function del(id, flags) {
87
+ async function get(id, flags) {
56
88
  const config = requireConfig();
57
- const orgId = flags.org || config.default_org;
58
- if (!orgId || !id) {
59
- error("Missing required arguments.\nUsage: myapi image delete <job_id> --org <id>\n(Or set defaults via: myapi config set-org <id>)");
89
+ const orgId = requireOrg(flags, config, 'myapi image get <job_id> [--org <id>] [--json]');
90
+ requireArg(id, 'job_id', 'myapi image get <job_id> [--org <id>] [--json]');
91
+ const job = await sdkImage.getImageJob(config.api_key, orgId, id);
92
+ if (flags.json) {
93
+ printJson(job);
94
+ return;
60
95
  }
96
+ info(`ID: ${job.job_id}`);
97
+ info(`Status: ${job.status}`);
98
+ info(`Aspect ratio: ${job.aspect_ratio}`);
99
+ if (job.prompt)
100
+ info(`Prompt: ${job.prompt}`);
101
+ if (job.url)
102
+ info(`URL: ${job.url}`);
103
+ if (job.error)
104
+ info(`Error: ${job.error}`);
105
+ if (job.created_at)
106
+ info(`Created: ${formatDate(job.created_at)}`);
107
+ }
108
+ // `get-url`: just the URL string, nothing else. Curl-friendly. Errors if
109
+ // the job isn't completed (no URL to print).
110
+ async function getUrl(id, _flags) {
111
+ const config = requireConfig();
112
+ const orgId = requireOrg(_flags, config, 'myapi image get-url <job_id> [--org <id>]');
113
+ requireArg(id, 'job_id', 'myapi image get-url <job_id> [--org <id>]');
114
+ const job = await sdkImage.getImageJob(config.api_key, orgId, id);
115
+ if (!job.url) {
116
+ error(`Image ${id} has no URL yet (status: ${job.status}). Re-check with: myapi image get ${id}`);
117
+ }
118
+ console.log(job.url);
119
+ }
120
+ async function del(id, flags) {
121
+ const config = requireConfig();
122
+ const orgId = requireOrg(flags, config, 'myapi image delete <job_id> [--org <id>]');
123
+ requireArg(id, 'job_id', 'myapi image delete <job_id> [--org <id>]');
61
124
  await sdkImage.deleteImage(config.api_key, orgId, id);
62
- success(`Image deleted! (Job ${id} history retained, URL nullified)`);
125
+ success(`Image ${id} deleted (job history retained, URL nullified)`);
63
126
  }
64
127
  // ── Dispatcher ───────────────────────────────────────────────────────────────
65
128
  const SUBCOMMAND_USAGE = {
66
129
  'list': 'myapi image list [--org <id>] [--json]',
67
- 'generate': 'myapi image generate --prompt "<text>" [--ratio <1:1|16:9>] [--style <style>] [--colors <hex_list>] [--org <id>]',
130
+ 'generate': `myapi image generate <prompt> [--ratio 1:1|16:9|9:16|4:3|3:4] [--style <s>] [--colors <hex,hex>] [--text] [--org <id>]
131
+ myapi image generate --prompt "<text>" ...
132
+
133
+ Either form works; the positional prompt is the recommended shape.
134
+
135
+ Generation is asynchronous — the CLI polls for up to 90s. If it times out
136
+ the job keeps running server-side; re-fetch with:
137
+ myapi image get <job_id>
138
+
139
+ Cost: $${PRICE_USD.toFixed(2)} per generation. Failed jobs aren't charged.
140
+
141
+ Flags:
142
+ --ratio Aspect ratio (allowed: ${[...VALID_RATIOS].join(', ')}; default 1:1)
143
+ --style Free-form style hint (e.g. "watercolor", "cyberpunk neon")
144
+ --colors Hex colors comma-separated (e.g. "#ff6600,#003366")
145
+ --text Allow text in the image (off by default — text rarely renders well)`,
146
+ 'get': `myapi image get <job_id> [--org <id>] [--json]
147
+
148
+ Round-trips the API to fetch the full job: status, URL, prompt, aspect ratio,
149
+ created_at. JSON output via --json; otherwise human-readable block.`,
150
+ 'get-url': `myapi image get-url <job_id> [--org <id>]
151
+
152
+ Prints just the asset URL. Errors if the job hasn't completed yet.
153
+ Curl-friendly:
154
+ curl -O "$(myapi image get-url <id>)"`,
68
155
  'delete': 'myapi image delete <job_id> [--org <id>]',
69
156
  };
70
157
  export async function run(subcommand, args, flags) {
@@ -72,11 +159,14 @@ export async function run(subcommand, args, flags) {
72
159
  info(`Usage: myapi image <subcommand>
73
160
 
74
161
  Subcommands:
75
- list List all your generated images
76
- generate Generate a new AI image from a prompt ($0.05 per generation)
77
- delete Delete the physical image file for a job (history retained, URL nullified)
162
+ list List all your generated images
163
+ generate <prompt> Generate a new AI image (async, polls up to 90s, $${PRICE_USD.toFixed(2)})
164
+ get <job_id> Fetch full job metadata (JSON or human-readable)
165
+ get-url <job_id> Print just the asset URL (curl-friendly)
166
+ delete <job_id> Delete the asset (job history retained)
78
167
 
79
- All commands accept --org <id> (or set default: myapi config set-org <id>).`);
168
+ All commands accept --org <id> (or set default: myapi config set-org <id>).
169
+ The asset lands in your org's storage and is also visible via "myapi storage list".`);
80
170
  return;
81
171
  }
82
172
  if (flags.help) {
@@ -84,13 +174,15 @@ All commands accept --org <id> (or set default: myapi config set-org <id>).`);
84
174
  if (usage)
85
175
  info(`Usage: ${usage}`);
86
176
  else
87
- info(`Unknown subcommand: ${subcommand}. Run "myapi image --help" for the list.`);
177
+ error(`Unknown subcommand: ${subcommand}. Run "myapi image --help" for the list.`);
88
178
  return;
89
179
  }
90
180
  switch (subcommand) {
91
181
  case 'list': return list(flags);
92
- case 'generate': return generate(flags);
182
+ case 'generate': return generate(args[0], flags);
183
+ case 'get': return get(args[0], flags);
184
+ case 'get-url': return getUrl(args[0], flags);
93
185
  case 'delete': return del(args[0], flags);
94
- default: error(`Unknown subcommand: ${subcommand}. Run "myapi image --help" for a list of valid subcommands.`);
186
+ default: error(`Unknown subcommand: ${subcommand}. Run "myapi image --help" for available subcommands.`);
95
187
  }
96
188
  }
@@ -1,4 +1,4 @@
1
- export declare function list(flags: Record<string, string | boolean>): Promise<void>;
2
- export declare function ingest(url: string, flags: Record<string, string | boolean>): Promise<void>;
3
- export declare function del(id: string, flags: Record<string, string | boolean>): Promise<void>;
4
- export declare function run(subcommand: string | undefined, args: string[], flags: Record<string, string | boolean>): Promise<void>;
1
+ import type { FlagSchema } from '../flags.js';
2
+ import { type Flags } from '../helpers.js';
3
+ export declare const SCHEMA: FlagSchema;
4
+ export declare function run(subcommand: string | undefined, args: string[], flags: Flags): Promise<void>;
@@ -1,39 +1,127 @@
1
- import { storage as sdkStorage } from '@myapihq/sdk';
1
+ import { readFile } from 'fs/promises';
2
+ import { extname, basename } from 'path';
3
+ import { storage as sdkStorage, STORAGE_BASE } from '@myapihq/sdk';
2
4
  import { requireConfig } from '../config.js';
3
5
  import { success, error, printTable, info, printJson } from '../output.js';
4
- export async function list(flags) {
6
+ import { formatDate } from '../utils.js';
7
+ import { requireOrg, requireArg } from '../helpers.js';
8
+ export const SCHEMA = {
9
+ org: 'string',
10
+ name: 'string',
11
+ };
12
+ // Mirrors UploadContentType in @myapihq/sdk + backend allowlist.
13
+ const EXT_TO_CT = {
14
+ '.png': 'image/png',
15
+ '.jpg': 'image/jpeg',
16
+ '.jpeg': 'image/jpeg',
17
+ '.gif': 'image/gif',
18
+ '.webp': 'image/webp',
19
+ };
20
+ function summarizeAsset(a) {
21
+ return {
22
+ asset_id: a.asset_id,
23
+ name: a.name || '(unnamed)',
24
+ url: a.url,
25
+ created_at: a.created_at ? formatDate(a.created_at) : '',
26
+ };
27
+ }
28
+ async function list(flags) {
5
29
  const config = requireConfig();
6
- const orgId = flags.org || config.default_org;
7
- if (!orgId)
8
- error("Missing required arguments.\nUsage: myapi storage list --org <id>\n(Or set defaults via: myapi config set-org <id>)");
30
+ const orgId = requireOrg(flags, config, 'myapi storage list [--org <id>]');
9
31
  const assets = await sdkStorage.listAssets(config.api_key, orgId);
10
- if (flags.json)
32
+ if (flags.json) {
11
33
  printJson(assets);
12
- else
13
- printTable(assets);
34
+ return;
35
+ }
36
+ printTable(assets.map(summarizeAsset), {
37
+ flags,
38
+ empty: 'No assets yet. Upload one: myapi storage upload <file> · Or ingest from URL: myapi storage ingest <url>',
39
+ });
14
40
  }
15
- export async function ingest(url, flags) {
41
+ async function ingest(url, flags) {
16
42
  const config = requireConfig();
17
- const orgId = flags.org || config.default_org;
18
- if (!orgId || !url) {
19
- error("Missing required arguments.\nUsage: myapi storage ingest <url> [--name <name>] --org <id>\n(Or set defaults via: myapi config set-org <id>)");
20
- }
43
+ const orgId = requireOrg(flags, config, 'myapi storage ingest <url> [--name <name>] [--org <id>]');
44
+ requireArg(url, 'url', 'myapi storage ingest <url> [--name <name>] [--org <id>]');
21
45
  const res = await sdkStorage.ingestAsset(config.api_key, orgId, url, flags.name);
22
46
  success(`Asset ingested! ID: ${res.asset_id}\nHosted URL: ${res.url}`);
23
47
  }
24
- export async function del(id, flags) {
48
+ async function upload(filePath, flags) {
25
49
  const config = requireConfig();
26
- const orgId = flags.org || config.default_org;
27
- if (!orgId || !id) {
28
- error("Missing required arguments.\nUsage: myapi storage delete <id> --org <id>\n(Or set defaults via: myapi config set-org <id>)");
50
+ const orgId = requireOrg(flags, config, 'myapi storage upload <file> [--name <name>] [--org <id>]');
51
+ requireArg(filePath, 'file', 'myapi storage upload <file> [--name <name>] [--org <id>]');
52
+ const ext = extname(filePath).toLowerCase();
53
+ const contentType = EXT_TO_CT[ext];
54
+ if (!contentType) {
55
+ error(`Unsupported file extension "${ext}". Supported: ${Object.keys(EXT_TO_CT).join(', ')}.\n(SVG and non-image types — pdf/mp4/webm — go through "myapi storage ingest <url>" today.)`);
56
+ }
57
+ let data;
58
+ try {
59
+ data = await readFile(filePath);
60
+ }
61
+ catch (e) {
62
+ if (e?.code === 'ENOENT')
63
+ error(`File not found: ${filePath}`);
64
+ if (e?.code === 'EACCES')
65
+ error(`Permission denied: ${filePath}`);
66
+ error(`Could not read ${filePath}: ${e?.message ?? e}`);
29
67
  }
68
+ const name = flags.name || basename(filePath);
69
+ const res = await sdkStorage.uploadAsset(config.api_key, orgId, data, contentType, name);
70
+ success(`Asset uploaded! ID: ${res.asset_id}\nHosted URL: ${res.url}`);
71
+ }
72
+ // `get`: round-trip metadata for one asset. Backend has no single-asset
73
+ // GET endpoint today, so we list + filter locally. Switch to a direct
74
+ // call once the backend exposes one.
75
+ async function get(id, flags) {
76
+ const config = requireConfig();
77
+ const orgId = requireOrg(flags, config, 'myapi storage get <asset_id> [--org <id>]');
78
+ requireArg(id, 'asset_id', 'myapi storage get <asset_id> [--org <id>]');
79
+ const all = await sdkStorage.listAssets(config.api_key, orgId);
80
+ const asset = all.find(a => a.asset_id === id);
81
+ if (!asset)
82
+ error(`Asset ${id} not found in this org. List with: myapi storage list`);
83
+ if (flags.json) {
84
+ printJson(asset);
85
+ return;
86
+ }
87
+ info(`ID: ${asset.asset_id}`);
88
+ info(`Name: ${asset.name || '(unnamed)'}`);
89
+ info(`URL: ${asset.url}`);
90
+ if (asset.created_at)
91
+ info(`Created: ${formatDate(asset.created_at)}`);
92
+ }
93
+ // `get-url`: cheapest possible — print the public URL pattern. No API
94
+ // call. Curl-friendly. Doesn't even verify the asset exists; that's a
95
+ // trade-off for being a pure local URL constructor.
96
+ async function getUrl(id, _flags) {
97
+ requireArg(id, 'asset_id', 'myapi storage get-url <asset_id>');
98
+ console.log(`${STORAGE_BASE}/storage/${encodeURIComponent(id)}`);
99
+ }
100
+ async function del(id, flags) {
101
+ const config = requireConfig();
102
+ const orgId = requireOrg(flags, config, 'myapi storage delete <asset_id> [--org <id>]');
103
+ requireArg(id, 'asset_id', 'myapi storage delete <asset_id> [--org <id>]');
30
104
  await sdkStorage.deleteAsset(config.api_key, orgId, id);
31
105
  success(`Asset ${id} deleted`);
32
106
  }
33
107
  // ── Dispatcher ───────────────────────────────────────────────────────────────
34
108
  const SUBCOMMAND_USAGE = {
35
109
  'list': 'myapi storage list [--org <id>] [--json]',
36
- 'ingest': 'myapi storage ingest <url> [--name <name>] [--org <id>]',
110
+ 'ingest': `myapi storage ingest <url> [--name <name>] [--org <id>]
111
+
112
+ Server fetches the URL and stores the file. Useful for migrating assets that
113
+ already live on a public URL (e.g. importing brand assets from another host).`,
114
+ 'upload': `myapi storage upload <file> [--name <name>] [--org <id>]
115
+
116
+ Direct multipart upload of a local file. Supported: ${Object.keys(EXT_TO_CT).join(', ')}.
117
+ The display name defaults to the file's basename — override with --name.`,
118
+ 'get': `myapi storage get <asset_id> [--org <id>] [--json]
119
+
120
+ Round-trips the API to fetch the asset's metadata (name, URL, created_at).`,
121
+ 'get-url': `myapi storage get-url <asset_id>
122
+
123
+ Prints the public CDN URL with no API call. Curl-friendly:
124
+ curl -O "$(myapi storage get-url <id>)"`,
37
125
  'delete': 'myapi storage delete <asset_id> [--org <id>]',
38
126
  };
39
127
  export async function run(subcommand, args, flags) {
@@ -41,9 +129,12 @@ export async function run(subcommand, args, flags) {
41
129
  info(`Usage: myapi storage <subcommand>
42
130
 
43
131
  Subcommands:
44
- list List all your uploaded assets
45
- ingest Ingest a public image URL into your edge storage
46
- delete Delete a stored asset
132
+ list List all your stored assets
133
+ ingest <url> Server pulls a public URL into storage
134
+ upload <file> Direct upload of a local file
135
+ get <asset_id> Fetch asset metadata (name, URL, created_at)
136
+ get-url <asset_id> Print the public CDN URL (no API call)
137
+ delete <asset_id> Permanently delete a stored asset
47
138
 
48
139
  All commands accept --org <id> (or set default: myapi config set-org <id>).`);
49
140
  return;
@@ -53,13 +144,16 @@ All commands accept --org <id> (or set default: myapi config set-org <id>).`);
53
144
  if (usage)
54
145
  info(`Usage: ${usage}`);
55
146
  else
56
- info(`Unknown subcommand: ${subcommand}. Run "myapi storage --help" for the list.`);
147
+ error(`Unknown subcommand: ${subcommand}. Run "myapi storage --help" for the list.`);
57
148
  return;
58
149
  }
59
150
  switch (subcommand) {
60
151
  case 'list': return list(flags);
61
152
  case 'ingest': return ingest(args[0], flags);
153
+ case 'upload': return upload(args[0], flags);
154
+ case 'get': return get(args[0], flags);
155
+ case 'get-url': return getUrl(args[0], flags);
62
156
  case 'delete': return del(args[0], flags);
63
- default: error(`Unknown subcommand: ${subcommand}. Run "myapi storage --help" for a list of valid subcommands.`);
157
+ default: error(`Unknown subcommand: ${subcommand}. Run "myapi storage --help" for available subcommands.`);
64
158
  }
65
159
  }
@@ -13,16 +13,56 @@ export const SCHEMA = {
13
13
  // forms are accepted by the workflow runner. Keep this in sync if the
14
14
  // backend grows new step types.
15
15
  const SUPPORTED_STEP_TYPES = ['send_email', 'email', 'slack_message', 'slack'];
16
+ const SLACK_HOOK_RE = /^https:\/\/hooks\.slack\.com\/services\/[A-Za-z0-9_-]+\/[A-Za-z0-9_-]+\/[A-Za-z0-9_-]+/;
17
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
18
+ // Per-step-type required fields. Validated client-side so typos and
19
+ // hallucinated shapes fail fast, before the network call. Backend
20
+ // performs the same validation as defense in depth.
16
21
  function validateSteps(steps) {
17
22
  if (!Array.isArray(steps))
18
23
  error('--steps must be a JSON array of step objects.');
24
+ if (steps.length === 0)
25
+ error('--steps cannot be an empty array.');
19
26
  steps.forEach((s, i) => {
27
+ const where = `step ${i}`;
20
28
  if (!s || typeof s !== 'object')
21
- error(`step ${i}: must be a JSON object.`);
29
+ error(`${where}: must be a JSON object.`);
22
30
  if (!s.type)
23
- error(`step ${i}: missing required field "type". Supported: ${SUPPORTED_STEP_TYPES.join(', ')}`);
31
+ error(`${where}: missing required field "type". Supported: ${SUPPORTED_STEP_TYPES.join(', ')}`);
24
32
  if (!SUPPORTED_STEP_TYPES.includes(s.type)) {
25
- error(`step ${i}: unknown type "${s.type}". Supported: ${SUPPORTED_STEP_TYPES.join(', ')}`);
33
+ error(`${where}: unknown type "${s.type}". Supported: ${SUPPORTED_STEP_TYPES.join(', ')}`);
34
+ }
35
+ if (s.type === 'send_email' || s.type === 'email') {
36
+ const required = ['from', 'to', 'subject'];
37
+ for (const f of required) {
38
+ if (!s[f] || typeof s[f] !== 'string') {
39
+ error(`${where} (${s.type}): missing required field "${f}".`);
40
+ }
41
+ }
42
+ const bodyForms = ['body', 'html', 'template_id'].filter(f => s[f] !== undefined && s[f] !== '');
43
+ if (bodyForms.length === 0) {
44
+ error(`${where} (${s.type}): must include exactly one of "body", "html", or "template_id".`);
45
+ }
46
+ if (bodyForms.length > 1) {
47
+ error(`${where} (${s.type}): can only use one of "body", "html", "template_id" — got: ${bodyForms.join(', ')}.`);
48
+ }
49
+ if (s.template_vars !== undefined && !s.template_id) {
50
+ error(`${where} (${s.type}): "template_vars" only makes sense with "template_id".`);
51
+ }
52
+ if (s.template_vars !== undefined && (typeof s.template_vars !== 'object' || Array.isArray(s.template_vars))) {
53
+ error(`${where} (${s.type}): "template_vars" must be a JSON object.`);
54
+ }
55
+ }
56
+ if (s.type === 'slack_message' || s.type === 'slack') {
57
+ if (!s.webhook_url || typeof s.webhook_url !== 'string') {
58
+ error(`${where} (${s.type}): missing required field "webhook_url".`);
59
+ }
60
+ if (!SLACK_HOOK_RE.test(s.webhook_url)) {
61
+ error(`${where} (${s.type}): "webhook_url" must look like https://hooks.slack.com/services/T.../B.../xxx — got "${s.webhook_url}".`);
62
+ }
63
+ if (!s.text || typeof s.text !== 'string') {
64
+ error(`${where} (${s.type}): missing required field "text".`);
65
+ }
26
66
  }
27
67
  });
28
68
  }
@@ -77,6 +117,9 @@ export async function create(nameArg, flags) {
77
117
  if (!name || !endpointId || !flags.steps) {
78
118
  error('Missing required arguments.\nUsage: myapi workflow create <name> --endpoint-id <id> --steps <json> [--no-enable] [--org <id>]\n or: myapi workflow create --name <name> --endpoint-id <id> --steps <json> [--no-enable] [--org <id>]');
79
119
  }
120
+ if (!UUID_RE.test(endpointId)) {
121
+ error(`Invalid --endpoint-id "${endpointId}". Expected a webhook endpoint UUID.\nList your endpoints with: myapi webhook list`);
122
+ }
80
123
  let steps;
81
124
  try {
82
125
  steps = JSON.parse(flags.steps);
@@ -107,8 +150,13 @@ export async function update(id, flags) {
107
150
  const payload = {};
108
151
  if (typeof flags.name === 'string')
109
152
  payload.name = flags.name;
110
- if (typeof flags['endpoint-id'] === 'string')
111
- payload.trigger_config = { endpoint_id: flags['endpoint-id'] };
153
+ if (typeof flags['endpoint-id'] === 'string') {
154
+ const eid = flags['endpoint-id'];
155
+ if (!UUID_RE.test(eid)) {
156
+ error(`Invalid --endpoint-id "${eid}". Expected a webhook endpoint UUID.\nList your endpoints with: myapi webhook list`);
157
+ }
158
+ payload.trigger_config = { endpoint_id: eid };
159
+ }
112
160
  if (typeof flags.steps === 'string') {
113
161
  try {
114
162
  payload.steps = JSON.parse(flags.steps);
@@ -180,19 +228,48 @@ const SUBCOMMAND_USAGE = {
180
228
 
181
229
  Either form works; the positional name is the recommended shape.
182
230
 
183
- --steps is a JSON array of step objects. Supported step types:
184
- send_email | email — fields: from, to, subject, body | template_id, template_vars
185
- slack_message | slack — fields: webhook_url, text
231
+ --steps is a JSON array of step objects. Each step has a "type" field
232
+ plus type-specific fields. Two types are supported today:
233
+
234
+ type: "send_email" (alias: "email")
235
+ Required:
236
+ from Sender mailbox address (must be activated for sending)
237
+ to Recipient address (templating allowed)
238
+ subject Email subject (templating allowed)
239
+ Body — exactly one of:
240
+ body Plain text body
241
+ html Raw HTML body
242
+ template_id UUID of an AI-generated template (myapi email template
243
+ generate). Recommended for nice-looking emails.
244
+ Optional:
245
+ template_vars JSON object of variable substitutions, only with
246
+ template_id (e.g. {"name": "{{ payload.name }}"}).
247
+
248
+ type: "slack_message" (alias: "slack")
249
+ Required:
250
+ webhook_url https://hooks.slack.com/services/T.../B.../xxx
251
+ Get one from https://api.slack.com/apps → your app →
252
+ Incoming Webhooks. Other URLs are rejected.
253
+ text Message text (templating allowed)
254
+
255
+ Templating: any string field can reference the incoming webhook payload
256
+ with {{ payload.field }} (whitespace optional). Example:
257
+ "to": "{{ payload.email }}" → the email field from the form submission.
258
+
259
+ Unknown step types AND missing/invalid fields are rejected at create
260
+ time — you find out about typos before any workflow run happens.
186
261
 
187
- Either alias works (e.g. type: "email" and type: "send_email" both run
188
- the same step). Field values support {{ payload.field }} templating to
189
- reference the incoming webhook payload, e.g. "to": "{{ payload.email }}".
190
- Unknown step types are rejected at create time, not at execute time.
262
+ There is NO on-failure notification mechanism today. Inspect run history
263
+ with: myapi workflow runs <id> · myapi workflow get-run <run_id>.
191
264
 
192
- Example — fire on every webhook POST, send a thank-you email:
265
+ Examples:
266
+ # send a thank-you email using an AI template, then ping Slack
193
267
  myapi workflow create "Contact handler" --endpoint-id <wid> --steps '[
194
- {"type":"send_email","from":"hello@x.com","to":"{{ payload.email }}",
195
- "subject":"Thanks!","body":"We got your message."}
268
+ {"type":"email","from":"hello@x.com","to":"{{ payload.email }}",
269
+ "subject":"Thanks!","template_id":"<tid>",
270
+ "template_vars":{"name":"{{ payload.name }}"}},
271
+ {"type":"slack","webhook_url":"https://hooks.slack.com/services/T0/B0/xxx",
272
+ "text":"New: {{ payload.message }}"}
196
273
  ]'
197
274
 
198
275
  See the my-workflow-api skill for the full form-to-email recipe.`,
package/dist/flags.js CHANGED
@@ -16,6 +16,7 @@ export function parseFlags(argv, schema = {}) {
16
16
  const merged = { ...GLOBAL_FLAGS, ...schema };
17
17
  const args = [];
18
18
  const flags = {};
19
+ const unknownFlags = [];
19
20
  for (let i = 0; i < argv.length; i++) {
20
21
  const arg = argv[i];
21
22
  if (arg === '-h') {
@@ -44,9 +45,24 @@ export function parseFlags(argv, schema = {}) {
44
45
  }
45
46
  const type = merged[key];
46
47
  // Unknown flags: tolerate as boolean (with --key=value still honored)
47
- // so additions to the schema don't silently break someone's script.
48
+ // so additions to the schema don't silently break someone's script
49
+ // BUT also collect them and emit a stderr warning at the end of
50
+ // parsing. Surfaces typos and AI-agent hallucinations like
51
+ // `--on-error <email>` without breaking back-compat.
48
52
  if (type === undefined) {
49
53
  flags[key] = inlineValue ?? true;
54
+ unknownFlags.push(`--${key}`);
55
+ // If no inline value and the next token doesn't look like another
56
+ // flag, the user almost certainly intended it as the flag's value
57
+ // (e.g. `--on-error someone@x.com`). Consume it so it doesn't
58
+ // become a stray positional arg later.
59
+ if (inlineValue === undefined) {
60
+ const next = argv[i + 1];
61
+ if (next !== undefined && !next.startsWith('--') && !next.startsWith('-h') && !next.startsWith('-v')) {
62
+ flags[key] = next;
63
+ i++;
64
+ }
65
+ }
50
66
  continue;
51
67
  }
52
68
  if (type === 'boolean') {
@@ -84,5 +100,11 @@ export function parseFlags(argv, schema = {}) {
84
100
  }
85
101
  args.push(arg);
86
102
  }
103
+ // Surface unknown flags so typos and hallucinated flags don't silently
104
+ // disappear. Don't block — the value is still in `flags` for any handler
105
+ // that wants it — just print one line on stderr.
106
+ if (unknownFlags.length > 0 && !process.env.MYAPI_QUIET_UNKNOWN_FLAGS) {
107
+ process.stderr.write(`› Note: ignoring unknown flag(s): ${unknownFlags.join(', ')}\n`);
108
+ }
87
109
  return { args, flags };
88
110
  }
package/dist/index.js CHANGED
@@ -16,6 +16,8 @@ import * as funnelCmd from './commands/funnel.js';
16
16
  import * as webhookCmd from './commands/webhook.js';
17
17
  import * as workflowCmd from './commands/workflow.js';
18
18
  import * as emailCmd from './commands/email/index.js';
19
+ import * as imageCmd from './commands/image.js';
20
+ import * as storageCmd from './commands/storage.js';
19
21
  import * as authCmd from './commands/auth.js';
20
22
  import * as configCmd from './commands/config.js';
21
23
  // Each command file declares the value flags it understands. We union them
@@ -28,8 +30,10 @@ const COMBINED_SCHEMA = {
28
30
  ...domainCmd.SCHEMA,
29
31
  ...emailCmd.SCHEMA,
30
32
  ...funnelCmd.SCHEMA,
33
+ ...imageCmd.SCHEMA,
31
34
  ...keysCmd.SCHEMA,
32
35
  ...orgCmd.SCHEMA,
36
+ ...storageCmd.SCHEMA,
33
37
  ...webhookCmd.SCHEMA,
34
38
  ...workflowCmd.SCHEMA,
35
39
  // Top-level flags
@@ -120,6 +124,12 @@ async function main() {
120
124
  case 'email':
121
125
  await emailCmd.run(subcommand, restArgs, flags);
122
126
  break;
127
+ case 'image':
128
+ await imageCmd.run(subcommand, restArgs, flags);
129
+ break;
130
+ case 'storage':
131
+ await storageCmd.run(subcommand, restArgs, flags);
132
+ break;
123
133
  // Convenience aliases
124
134
  case 'setup':
125
135
  await setupCmd.setup(flags);
@@ -202,6 +212,8 @@ const HELP_TARGETS = {
202
212
  webhook: f => webhookCmd.run(undefined, [], f),
203
213
  workflow: f => workflowCmd.run(undefined, [], f),
204
214
  email: f => emailCmd.run(undefined, [], f),
215
+ image: f => imageCmd.run(undefined, [], f),
216
+ storage: f => storageCmd.run(undefined, [], f),
205
217
  org: f => orgCmd.run(undefined, [], f),
206
218
  billing: f => billingCmd.run(undefined, [], f),
207
219
  keys: f => keysCmd.run(undefined, [], f),
@@ -244,6 +256,8 @@ Commands:
244
256
  webhook Manage inbound webhook endpoints and inspect deliveries
245
257
  email Manage mailboxes, send/read email, templates, and campaigns
246
258
  workflow Run actions (send email, post to Slack) when a webhook fires
259
+ image Generate AI images and manage them in storage
260
+ storage Upload, ingest, list, and serve assets from edge storage
247
261
 
248
262
  Aliases:
249
263
  whoami → myapi auth whoami
package/dist/output.d.ts CHANGED
@@ -7,6 +7,7 @@ export declare function info(message: string): void;
7
7
  export declare function banner(message: string): void;
8
8
  export declare function printJson(data: unknown): void;
9
9
  export declare function spinnerFrame(i: number): string;
10
+ export declare function spinnerWrite(s: string): void;
10
11
  export declare function clearLine(): void;
11
12
  export interface PrintTableOptions {
12
13
  /** Pass `flags` so `--json` (any truthy form) routes to JSON output. */
package/dist/output.js CHANGED
@@ -17,12 +17,19 @@ export function printJson(data) {
17
17
  }
18
18
  // Spinner / line-clear primitives. Used by polling helpers (utils.pollJob)
19
19
  // and any handler that wants its own progress UI.
20
+ //
21
+ // Spinner writes go to STDERR — keeping stdout clean for whatever the
22
+ // command's actual output is (JSON, an id, a URL). Otherwise spinner bytes
23
+ // pollute `--json` parsing or piped output.
20
24
  const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
21
25
  export function spinnerFrame(i) {
22
26
  return SPINNER_FRAMES[i % SPINNER_FRAMES.length];
23
27
  }
28
+ export function spinnerWrite(s) {
29
+ process.stderr.write(s);
30
+ }
24
31
  export function clearLine() {
25
- process.stdout.write('\r\x1b[K');
32
+ process.stderr.write('\r\x1b[K');
26
33
  }
27
34
  // Generic so callers don't need `as unknown as Record<string, unknown>[]`.
28
35
  // Field names come from the first row; if SDK renames a field, the projector
@@ -0,0 +1,32 @@
1
+ ---
2
+ # my-image-api
3
+
4
+ Generate AI images from a text prompt. Async — submit, poll, get a public CDN URL. Asset auto-saved to your org's storage.
5
+
6
+ ## What it does
7
+
8
+ - Text-to-image generation with prompt, aspect ratio, style, colors, optional text
9
+ - Async pipeline (~10–30s typical, 90s timeout)
10
+ - Output stored in mystorageapi automatically
11
+ - Public CDN URL for embedding in funnels, emails, anywhere
12
+
13
+ ## Quickstart
14
+
15
+ ```bash
16
+ myapi image generate "A clean flat-color logo for a sustainable jam company"
17
+ # → polls, then prints the asset URL
18
+ ```
19
+
20
+ ## Authentication
21
+
22
+ ```bash
23
+ export MYAPI_KEY=mak_...
24
+ ```
25
+
26
+ Requires `api_key` and `org_id` from **myapihq**. ~$0.05 per generation.
27
+
28
+ ## Documentation
29
+
30
+ Full command reference, flags, and async semantics: see `SKILL.md`.
31
+
32
+ Run `myapi image --help` for inline reference.
@@ -0,0 +1,74 @@
1
+ ---
2
+ name: my-image-api
3
+ description: >
4
+ Generate AI images from a text prompt. Async — submit a prompt, the CLI polls until the image is ready, then returns a public CDN URL. Image lands automatically in your org's storage.
5
+ ---
6
+
7
+ # MyImageAPI
8
+
9
+ Text-to-image generation. Submit a prompt with optional aspect ratio, style, color palette, and "allow text" hint. The job is asynchronous — the CLI polls until completion (typically 10–30s, capped at 90s).
10
+
11
+ ## How It Fits Together
12
+
13
+ - Requires `api_key` and `org_id` from **myapihq**.
14
+ - Each generation costs ~$0.05 (deducted from your balance).
15
+ - The output asset is stored in your org's **mystorageapi** bucket — `myapi storage list` and `myapi storage get` can fetch it later by id.
16
+ - Generated images can be referenced from a **myfunnelapi** page or embedded in a **myemailapi** template.
17
+
18
+ ## Quick Start
19
+
20
+ ```bash
21
+ # Generate (positional prompt is the recommended shape)
22
+ myapi image generate "A clean flat-color logo for a sustainable jam company"
23
+
24
+ # Same with options
25
+ myapi image generate "Hero image, 16:9, mountains at dawn" \
26
+ --ratio 16:9 --style "watercolor" --colors "#ff6600,#003366"
27
+
28
+ # List your generations
29
+ myapi image list
30
+
31
+ # Inspect or fetch a specific job
32
+ myapi image get <job_id>
33
+
34
+ # Delete the asset (the job history record stays)
35
+ myapi image delete <job_id>
36
+ ```
37
+
38
+ ## All Commands
39
+
40
+ | Command | What it does |
41
+ |---|---|
42
+ | `myapi image generate <prompt>` | Async generate, polls up to 90s, returns id + URL |
43
+ | `myapi image list` | List all generated images for the org |
44
+ | `myapi image get <job_id>` | Get full job details (status, URL, prompt, aspect ratio) |
45
+ | `myapi image delete <job_id>` | Delete the asset (job record kept for history) |
46
+
47
+ ## Generate Flags
48
+
49
+ | Flag | Allowed | Default | Notes |
50
+ |---|---|---|---|
51
+ | `--ratio` | `1:1`, `16:9`, `9:16`, `4:3`, `3:4` | `1:1` | Aspect ratio of the output |
52
+ | `--style` | free-form string | none | Style hint, e.g. `"watercolor"`, `"cyberpunk neon"` |
53
+ | `--colors` | hex list, comma-separated | none | e.g. `"#ff6600,#003366"` — the model will bias toward these |
54
+ | `--text` | flag (no value) | off | Allow text in the image. Off by default — text rarely renders well, use only when you specifically want a logo or sign |
55
+
56
+ ## Async Behavior
57
+
58
+ Generation is async. The CLI:
59
+ 1. Submits the prompt → gets a `job_id` immediately.
60
+ 2. Polls `GET /image/orgs/<org>/jobs/<job_id>` every 3 seconds.
61
+ 3. Returns when status is `completed` (success) or `failed` (errors out).
62
+ 4. If 90 seconds elapse without completion, the CLI prints a timeout message but **the job keeps running server-side**. Check back with:
63
+ ```
64
+ myapi image get <job_id>
65
+ ```
66
+
67
+ ## Notes
68
+
69
+ - Generations cost $0.05 each. Failed jobs aren't charged.
70
+ - The asset URL is public (anyone with the URL can view) — don't generate sensitive content.
71
+ - Prompts with explicit text usually fail; for branded text use a separate text overlay step or a real designer.
72
+ - Delete is permanent for the asset; the job's prompt + metadata stays for your history (`image list` will still show it with an empty URL).
73
+
74
+ Run `myapi image --help` or `myapi image <subcommand> --help` for full flag reference.
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "my-image-api",
3
+ "description": "Generate AI images from a text prompt. Async, polls until ready, returns a public CDN URL.",
4
+ "version": "1.0.0",
5
+ "published": true
6
+ }
File without changes
File without changes
File without changes
@@ -0,0 +1,35 @@
1
+ ---
2
+ # my-storage-api
3
+
4
+ Edge-hosted asset storage. Upload local files or ingest from URLs. Each asset gets a stable public CDN URL.
5
+
6
+ ## What it does
7
+
8
+ - Direct multipart upload of `.png` / `.jpg` files
9
+ - Ingest from any public URL (server pulls)
10
+ - Public CDN delivery — stable URL until deletion
11
+ - Used automatically by my-image-api for generated images
12
+
13
+ ## Quickstart
14
+
15
+ ```bash
16
+ myapi storage upload ./logo.png
17
+ # → asset uploaded! URL: https://api.mystorageapi.com/storage/<id>
18
+
19
+ # Or fetch via curl
20
+ curl -O "$(myapi storage get <asset_id>)"
21
+ ```
22
+
23
+ ## Authentication
24
+
25
+ ```bash
26
+ export MYAPI_KEY=mak_...
27
+ ```
28
+
29
+ Requires `api_key` and `org_id` from **myapihq**.
30
+
31
+ ## Documentation
32
+
33
+ Full command reference, upload-vs-ingest tradeoffs, and naming conventions: see `SKILL.md`.
34
+
35
+ Run `myapi storage --help` for inline reference.
@@ -0,0 +1,88 @@
1
+ ---
2
+ name: my-storage-api
3
+ description: >
4
+ Edge-hosted asset storage. Upload local files (.png/.jpg) directly, or have the server fetch from a public URL. Each asset gets a stable public CDN URL.
5
+ ---
6
+
7
+ # MyStorageAPI
8
+
9
+ Per-org asset storage with edge CDN delivery. Two ways in:
10
+ - **Direct upload** — push a local file (multipart upload)
11
+ - **Ingest from URL** — server fetches a public URL and stores the file
12
+
13
+ Both produce a stable public URL like `https://api.mystorageapi.com/storage/<id>`.
14
+
15
+ ## How It Fits Together
16
+
17
+ - Requires `api_key` and `org_id` from **myapihq**.
18
+ - Upload supports `.png`, `.jpg`, `.jpeg` today. (Add more via `mystorageapi`'s backend if you need them.)
19
+ - Assets are public — anyone with the URL can fetch.
20
+ - **myimageapi** automatically uses storage for generated images, so generated images appear in `myapi storage list` too.
21
+
22
+ ## Quick Start
23
+
24
+ ```bash
25
+ # Direct upload
26
+ myapi storage upload ./logo.png --name "brand-logo"
27
+
28
+ # Ingest a public URL (server pulls)
29
+ myapi storage ingest https://example.com/hero.jpg --name "hero"
30
+
31
+ # List
32
+ myapi storage list
33
+
34
+ # Get the public URL (curl-friendly)
35
+ curl -O "$(myapi storage get <asset_id>)"
36
+
37
+ # Delete
38
+ myapi storage delete <asset_id>
39
+ ```
40
+
41
+ ## All Commands
42
+
43
+ | Command | What it does |
44
+ |---|---|
45
+ | `myapi storage list` | List all stored assets |
46
+ | `myapi storage upload <file>` | Direct multipart upload of a local file |
47
+ | `myapi storage ingest <url>` | Server fetches a public URL into storage |
48
+ | `myapi storage get <asset_id>` | Print the public CDN URL (no API call) |
49
+ | `myapi storage delete <asset_id>` | Permanently delete the asset |
50
+
51
+ ## Upload vs Ingest
52
+
53
+ Pick based on where the file is:
54
+
55
+ - **`upload`** — file is on your machine. Multipart POST. Constraint: `.png` / `.jpg` / `.jpeg` only today.
56
+ - **`ingest`** — file is at a public HTTP(S) URL. Server downloads and stores. Useful for migrating assets from another host or pulling in third-party images you have rights to.
57
+
58
+ Both produce identical asset records — `list` doesn't distinguish them.
59
+
60
+ ## Naming
61
+
62
+ Both `upload` and `ingest` accept `--name <display>` for a human-friendly label. If omitted:
63
+ - `upload` defaults to the filename's basename.
64
+ - `ingest` defaults to the URL path's filename or empty.
65
+
66
+ The display name is for your reference only — the public URL uses the auto-generated id.
67
+
68
+ ## Public URLs
69
+
70
+ `myapi storage get <id>` is a pure-local URL constructor — no API call, no auth, no rate limit. The output is exactly:
71
+ ```
72
+ https://api.mystorageapi.com/storage/<id>
73
+ ```
74
+ (or whatever `MYAPI_STORAGE_URL` is set to). Pipe it directly into curl:
75
+ ```bash
76
+ curl -O "$(myapi storage get abc123)"
77
+ ```
78
+
79
+ The URL itself is permanent until you `myapi storage delete <id>` — embed it freely in your funnels, emails, or anywhere else.
80
+
81
+ ## Notes
82
+
83
+ - Assets are public by default. Don't store sensitive files.
84
+ - Delete is immediate and unrecoverable.
85
+ - Generated images from **myimageapi** show up in `storage list` under their job id.
86
+ - If you need a non-image format (PDF, video, etc.), `ingest` works as long as the server-side storage accepts it; `upload` is restricted to image types.
87
+
88
+ Run `myapi storage --help` for full flag reference.
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "my-storage-api",
3
+ "description": "Edge-hosted asset storage. Upload local files or ingest from URLs, each gets a stable public CDN URL.",
4
+ "version": "1.0.0",
5
+ "published": true
6
+ }
File without changes
File without changes
File without changes
package/dist/utils.js CHANGED
@@ -1,4 +1,4 @@
1
- import { spinnerFrame, clearLine, error } from './output.js';
1
+ import { spinnerFrame, spinnerWrite, clearLine, error } from './output.js';
2
2
  export function sleep(ms) {
3
3
  return new Promise(resolve => setTimeout(resolve, ms));
4
4
  }
@@ -23,7 +23,7 @@ export function formatDate(str) {
23
23
  export async function pollJob(opts) {
24
24
  const timeoutMs = opts.timeoutMs ?? 90_000;
25
25
  const intervalMs = opts.intervalMs ?? 3_000;
26
- process.stdout.write(`${opts.label} `);
26
+ spinnerWrite(`${opts.label} `);
27
27
  let i = 0;
28
28
  let elapsed = 0;
29
29
  while (elapsed < timeoutMs) {
@@ -36,7 +36,7 @@ export async function pollJob(opts) {
36
36
  clearLine();
37
37
  error(opts.failedMessage ?? `${opts.label} failed`);
38
38
  }
39
- process.stdout.write(`\r${opts.label} ${spinnerFrame(i++)}`);
39
+ spinnerWrite(`\r${opts.label} ${spinnerFrame(i++)}`);
40
40
  await sleep(intervalMs);
41
41
  elapsed += intervalMs;
42
42
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myapihq/cli",
3
- "version": "1.1.0-wip.2",
3
+ "version": "1.1.0-wip.4",
4
4
  "description": "MyAPI command-line interface",
5
5
  "type": "module",
6
6
  "files": [