@buildinternet/uploads 0.34.1 → 0.35.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -50,6 +50,13 @@ Commands: `attach`, `put`, `screenshot`, `annotate`, `gallery`, `comment`, `list
50
50
  `uploads help --all` (or `--help --all`) for the full command list. Per-command:
51
51
  `uploads <cmd> --help`.
52
52
 
53
+ **Errors:** every failure prints a short `error:` line on stderr, followed by a
54
+ runnable example when an argument is missing, then a hint — never a help dump,
55
+ so trimmed output (`| tail`) still carries the reason.
56
+ A mistyped command also suggests the closest real one: `uploads set-metadata`
57
+ answers `did you mean: uploads meta set`. With `--json`, an unknown command
58
+ returns `{ "error", "code": "USAGE", "didYouMean" }` on stdout.
59
+
53
60
  **Shell completion:** `uploads completion bash|zsh|fish` prints a script to
54
61
  stdout. Example (zsh): `uploads completion zsh > ~/.zsh/completions/_uploads`.
55
62
 
@@ -24,7 +24,16 @@ export declare function isHelpFlag(arg: string): boolean;
24
24
  */
25
25
  export declare function parseArgv(argv: string[]): ParsedArgv;
26
26
  export declare class UsageError extends Error {
27
- constructor(message: string);
27
+ /**
28
+ * A correct invocation to show under the error. Agents pattern-match on a
29
+ * runnable line far better than on prose, so every "you left out a required
30
+ * argument" error should carry one — the CLI prints it, and `--json`
31
+ * callers get it as `example`.
32
+ */
33
+ readonly example?: string;
34
+ constructor(message: string, options?: {
35
+ example?: string;
36
+ });
28
37
  }
29
38
  export interface CommandFlags {
30
39
  positionals: string[];
package/dist/cli-args.js CHANGED
@@ -69,9 +69,17 @@ export function parseArgv(argv) {
69
69
  return { globals, help, command: rest[0], rest };
70
70
  }
71
71
  export class UsageError extends Error {
72
- constructor(message) {
72
+ /**
73
+ * A correct invocation to show under the error. Agents pattern-match on a
74
+ * runnable line far better than on prose, so every "you left out a required
75
+ * argument" error should carry one — the CLI prints it, and `--json`
76
+ * callers get it as `example`.
77
+ */
78
+ example;
79
+ constructor(message, options = {}) {
73
80
  super(message);
74
81
  this.name = "UsageError";
82
+ this.example = options.example;
75
83
  }
76
84
  }
77
85
  /** Records a flag occurrence, turning a repeated string flag into an array. */
@@ -17,6 +17,24 @@ export interface RootHelpOptions {
17
17
  */
18
18
  needsAuth?: boolean;
19
19
  }
20
+ export interface UnknownCommandOptions {
21
+ /** The command the caller typed. */
22
+ command: string;
23
+ /** Suggested command phrase, from `suggestCommand`. */
24
+ suggestion?: string;
25
+ /** Catalog summary for the suggestion, shown beside it. */
26
+ summary?: string;
27
+ color?: boolean;
28
+ style?: CliStyle;
29
+ }
30
+ /**
31
+ * Unknown-command output: deliberately a handful of lines, not the root help
32
+ * dump (issue #545). Agents trim command output (`| tail -20`), and a 27-line
33
+ * banner pushed the one line that mattered — the error — out of the window.
34
+ * Short output survives truncation, and the pointers below still lead to the
35
+ * full list.
36
+ */
37
+ export declare function formatUnknownCommand(options: UnknownCommandOptions): string;
20
38
  /**
21
39
  * Root help text. Default is a short essentials view; pass `full: true` for
22
40
  * the complete command + config dump (`uploads help --all`).
package/dist/cli-help.js CHANGED
@@ -175,6 +175,24 @@ ${style.muted("Tip: uploads help essentials only")}
175
175
  ${style.muted(" uploads help --all this full listing")}
176
176
  `;
177
177
  }
178
+ /**
179
+ * Unknown-command output: deliberately a handful of lines, not the root help
180
+ * dump (issue #545). Agents trim command output (`| tail -20`), and a 27-line
181
+ * banner pushed the one line that mattered — the error — out of the window.
182
+ * Short output survives truncation, and the pointers below still lead to the
183
+ * full list.
184
+ */
185
+ export function formatUnknownCommand(options) {
186
+ const style = options.style ??
187
+ createStyle(options.color !== undefined ? options.color : colorEnabled(process.stderr));
188
+ const lines = [style.error(`unknown command: ${options.command}`)];
189
+ if (options.suggestion) {
190
+ const summary = options.summary ? ` ${style.body(options.summary)}` : "";
191
+ lines.push("", `did you mean: ${style.command(`uploads ${options.suggestion}`)}${summary}`);
192
+ }
193
+ lines.push("", ` ${padCmd("uploads help --all", CMD_WIDTH, style)}${style.body("Full command list")}`, ` ${padCmd("uploads <cmd> --help", CMD_WIDTH, style)}${style.body("Per-command options and examples")}`);
194
+ return `${lines.join("\n")}\n`;
195
+ }
178
196
  /**
179
197
  * Root help text. Default is a short essentials view; pass `full: true` for
180
198
  * the complete command + config dump (`uploads help --all`).
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The command phrase a mistyped command most likely meant, or undefined when
3
+ * nothing is close enough to be worth printing. Aliases win over distance so
4
+ * `metadata` resolves to `meta set` rather than the bare `meta` group.
5
+ */
6
+ export declare function suggestCommand(input: string): string | undefined;
7
+ /** Catalog summary for a suggested phrase (`meta set` → the subcommand's). */
8
+ export declare function commandSummary(phrase: string): string | undefined;
@@ -0,0 +1,151 @@
1
+ /**
2
+ * "Did you mean" for a mistyped root command (issue #545).
3
+ *
4
+ * The unknown-command path used to answer with the essentials list, which
5
+ * can't correct a wrong guess for anything outside it — `uploads set-metadata`
6
+ * printed a help dump that never mentions `meta`. Two sources feed a
7
+ * suggestion: a table of spellings agents actually reach for, and an edit
8
+ * distance over the catalog. Same rule as the metadata vocabulary
9
+ * (metadata-vocab.ts): suggest, never silently rewrite.
10
+ */
11
+ import { ROOT_COMMANDS } from "./cli-catalog.js";
12
+ /**
13
+ * Wrong-but-natural spellings, mapped to the command phrase that does the
14
+ * job. Values are full phrases (`meta set`), not just root names, because the
15
+ * work an agent wants often lives on a subcommand.
16
+ */
17
+ const COMMAND_ALIASES = {
18
+ // metadata — the case in issue #545
19
+ setmetadata: "meta set",
20
+ setmeta: "meta set",
21
+ metadata: "meta set",
22
+ metaset: "meta set",
23
+ tag: "meta set",
24
+ tags: "meta set",
25
+ label: "meta set",
26
+ getmetadata: "meta get",
27
+ getmeta: "meta get",
28
+ showmetadata: "meta get",
29
+ metaget: "meta get",
30
+ // upload
31
+ upload: "put",
32
+ uploadfile: "put",
33
+ cp: "put",
34
+ copy: "put",
35
+ send: "put",
36
+ // listing and search
37
+ ls: "list",
38
+ files: "list",
39
+ objects: "list",
40
+ search: "find",
41
+ query: "find",
42
+ filter: "find",
43
+ // removal
44
+ rm: "delete",
45
+ remove: "delete",
46
+ del: "delete",
47
+ destroy: "delete",
48
+ // capture
49
+ capture: "screenshot",
50
+ shot: "screenshot",
51
+ snap: "screenshot",
52
+ screengrab: "screenshot",
53
+ screencapture: "screenshot",
54
+ // session
55
+ signin: "login",
56
+ auth: "login",
57
+ authenticate: "login",
58
+ signout: "logout",
59
+ // github
60
+ pr: "attach",
61
+ issue: "attach",
62
+ upsertcomment: "comment",
63
+ };
64
+ /** Compare on letters and digits only, so `set-metadata` ≡ `set_metadata`. */
65
+ function normalize(value) {
66
+ return value.toLowerCase().replace(/[^a-z0-9]/g, "");
67
+ }
68
+ /** Plain Levenshtein distance (two-row); inputs here are a few characters. */
69
+ function editDistance(a, b) {
70
+ if (a === b)
71
+ return 0;
72
+ if (a.length === 0)
73
+ return b.length;
74
+ if (b.length === 0)
75
+ return a.length;
76
+ let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
77
+ for (let i = 1; i <= a.length; i++) {
78
+ const row = [i];
79
+ for (let j = 1; j <= b.length; j++) {
80
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
81
+ row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + cost);
82
+ }
83
+ prev = row;
84
+ }
85
+ return prev[b.length];
86
+ }
87
+ /** Crude de-pluralizer, so `galleries` still reaches `gallery`. */
88
+ function singular(value) {
89
+ if (value.endsWith("ies") && value.length > 4)
90
+ return `${value.slice(0, -3)}y`;
91
+ if (value.endsWith("es") && value.length > 3)
92
+ return value.slice(0, -2);
93
+ if (value.endsWith("s") && value.length > 3)
94
+ return value.slice(0, -1);
95
+ return value;
96
+ }
97
+ /** Short words tolerate one typo, longer ones two. */
98
+ function threshold(length) {
99
+ if (length <= 3)
100
+ return 0;
101
+ if (length <= 5)
102
+ return 1;
103
+ return 2;
104
+ }
105
+ /**
106
+ * The command phrase a mistyped command most likely meant, or undefined when
107
+ * nothing is close enough to be worth printing. Aliases win over distance so
108
+ * `metadata` resolves to `meta set` rather than the bare `meta` group.
109
+ */
110
+ export function suggestCommand(input) {
111
+ const norm = normalize(input);
112
+ if (!norm)
113
+ return undefined;
114
+ const alias = COMMAND_ALIASES[norm];
115
+ if (alias)
116
+ return alias;
117
+ let best;
118
+ const consider = (candidate, phrase, allowContainment) => {
119
+ // A plural or a trailing qualifier (`screenshots`, `put-file`) is the same
120
+ // intent, not a typo — treat containment as a very strong match.
121
+ const contained = allowContainment &&
122
+ candidate.length >= 3 &&
123
+ (norm.startsWith(candidate) || candidate.startsWith(norm));
124
+ const distance = contained
125
+ ? 0
126
+ : Math.min(editDistance(norm, candidate), editDistance(singular(norm), candidate));
127
+ if (!contained && distance > threshold(Math.max(norm.length, candidate.length)))
128
+ return;
129
+ if (!best || distance < best.distance)
130
+ best = { phrase, distance };
131
+ };
132
+ for (const cmd of ROOT_COMMANDS)
133
+ consider(normalize(cmd.name), cmd.name, true);
134
+ // Aliases join the fuzzy pass too, so a typo *of* a synonym (`uplaod`) still
135
+ // lands. No containment for these — an alias is a whole word, and `tag` as a
136
+ // prefix would swallow unrelated input.
137
+ for (const [spelling, phrase] of Object.entries(COMMAND_ALIASES)) {
138
+ consider(spelling, phrase, false);
139
+ }
140
+ return best?.phrase;
141
+ }
142
+ /** Catalog summary for a suggested phrase (`meta set` → the subcommand's). */
143
+ export function commandSummary(phrase) {
144
+ const [name, sub] = phrase.split(" ");
145
+ const cmd = ROOT_COMMANDS.find((c) => c.name === name);
146
+ if (!cmd)
147
+ return undefined;
148
+ if (!sub)
149
+ return cmd.summary;
150
+ return cmd.subcommands?.find((s) => s.name === sub)?.summary;
151
+ }
package/dist/cli.js CHANGED
@@ -2,7 +2,9 @@ import { createUploadsClient } from "./client.js";
2
2
  import { DEFAULT_API_URL, resolveApiUrl, resolveConfig } from "./config.js";
3
3
  import { UploadsError } from "./errors.js";
4
4
  import { commandWorkspace, flagString, isHelpFlag, parseArgv, parseCommandArgs, UsageError, } from "./cli-args.js";
5
- import { formatRootHelp, wantsFullHelp } from "./cli-help.js";
5
+ import { formatRootHelp, formatUnknownCommand, wantsFullHelp } from "./cli-help.js";
6
+ import { commandSummary, suggestCommand } from "./cli-suggest.js";
7
+ import { writeJson } from "./io.js";
6
8
  import { colorEnabled, createStyle } from "./cli-style.js";
7
9
  import { runPut, runAttach, runStaged, runList, runFind, runMeta, runDelete, runHealth, runDoctor, runComment, runGithub, runUsage, runReconcile, runPurgeExpired, runGallery, } from "./commands.js";
8
10
  import { runConfig } from "./commands/config.js";
@@ -117,7 +119,7 @@ function errorOut(err, format) {
117
119
  const payload = err instanceof UploadsError
118
120
  ? { error: err.message, code: err.code, status: err.status }
119
121
  : err instanceof UsageError
120
- ? { error: err.message, code: "USAGE" }
122
+ ? { error: err.message, code: "USAGE", ...(err.example ? { example: err.example } : {}) }
121
123
  : { error: err instanceof Error ? err.message : String(err) };
122
124
  if (format === "json") {
123
125
  process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
@@ -136,6 +138,11 @@ function errorOut(err, format) {
136
138
  process.stderr.write(`${msg}\n`);
137
139
  else
138
140
  process.stderr.write(`error: ${msg}\n`);
141
+ // A runnable line beats prose for both agents and humans; the `--help`
142
+ // pointer that follows stays for the full option list.
143
+ if (err instanceof UsageError && err.example) {
144
+ process.stderr.write(` ${err.example}\n`);
145
+ }
139
146
  if (err instanceof UploadsError) {
140
147
  const hint = ERROR_HINTS[err.code];
141
148
  if (hint)
@@ -353,13 +360,25 @@ export async function runCli(argv) {
353
360
  break;
354
361
  }
355
362
  default: {
356
- const style = createStyle(colorEnabled(process.stderr));
357
- process.stderr.write(`${style.error(`unknown command: ${parsed.command}`)}\n\n`);
358
- await writeRootHelp({
359
- full: false,
360
- token: parsed.globals.token,
361
- envFile: parsed.globals.envFile,
362
- });
363
+ // Short, greppable, and suggestion-first (issue #545) — never the root
364
+ // help dump, which buried the error when agents piped through `tail`.
365
+ const suggestion = suggestCommand(parsed.command);
366
+ const message = `unknown command: ${parsed.command}`;
367
+ if (json) {
368
+ await writeJson({
369
+ error: message,
370
+ code: "USAGE",
371
+ ...(suggestion ? { didYouMean: suggestion } : {}),
372
+ });
373
+ }
374
+ else {
375
+ process.stderr.write(formatUnknownCommand({
376
+ command: parsed.command,
377
+ suggestion,
378
+ summary: suggestion ? commandSummary(suggestion) : undefined,
379
+ style: createStyle(colorEnabled(process.stderr)),
380
+ }));
381
+ }
363
382
  flushTelemetry(2);
364
383
  return 2;
365
384
  }
@@ -48,8 +48,9 @@ readStdinImpl = readStdin) {
48
48
  }
49
49
  const imagePath = parsed.positionals[0];
50
50
  if (!imagePath) {
51
- writeCommandHelp(ANNOTATE_HELP);
52
- return 2;
51
+ throw new UsageError("annotate requires an image path", {
52
+ example: "uploads annotate ./shot.png --spec ./callouts.json",
53
+ });
53
54
  }
54
55
  if (parsed.positionals.length > 1) {
55
56
  throw new UsageError("annotate takes exactly one image argument");
@@ -59,9 +59,9 @@ export async function runConfig(args, opts, help = false) {
59
59
  case "set":
60
60
  return runConfigSet(rest, subArgs, opts, help);
61
61
  default:
62
- process.stderr.write(`unknown config subcommand: ${sub}\n\n`);
63
- writeCommandHelp(CONFIG_HELP);
64
- return 2;
62
+ // Short, not a help dump: agents trim output, and a dump pushes the
63
+ // reason out of the window (issue #545).
64
+ throw new UsageError(`unknown config subcommand: ${sub} (expected path, show, init, or set)`, { example: "uploads config show" });
65
65
  }
66
66
  }
67
67
  async function runConfigPath(args, opts, help) {
@@ -191,8 +191,9 @@ Examples:
191
191
  const key = positionals[0];
192
192
  const value = positionals[1];
193
193
  if (!key || !value) {
194
- writeCommandHelp(`uploads config set <key> <value>\n`);
195
- return 2;
194
+ throw new UsageError("config set requires a key and a value", {
195
+ example: "uploads config set UPLOADS_WORKSPACE myteam",
196
+ });
196
197
  }
197
198
  if (!VALID_KEYS.has(key)) {
198
199
  throw new UsageError(`unknown key: ${key} (expected ${[...VALID_KEYS].join(", ")})`);
@@ -156,8 +156,9 @@ loadAnnotateModule = () => import("../annotate/index.js")) {
156
156
  }
157
157
  const target = parsed.positionals[0];
158
158
  if (!target) {
159
- writeCommandHelp(SCREENSHOT_HELP);
160
- return 2;
159
+ throw new UsageError("screenshot requires a target URL or .html file", {
160
+ example: "uploads screenshot http://localhost:4321/settings --out settings.png",
161
+ });
161
162
  }
162
163
  if (parsed.positionals.length > 1) {
163
164
  throw new UsageError("screenshot takes exactly one target");
package/dist/commands.js CHANGED
@@ -941,8 +941,9 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
941
941
  // file args) would silently print help instead of a clear UsageError.
942
942
  const branchArg = branchFromFlags(parsed.flags, run);
943
943
  if (parsed.positionals.length === 0) {
944
- writeCommandHelp(ATTACH_HELP);
945
- return 2;
944
+ throw new UsageError("attach requires at least one file", {
945
+ example: "uploads attach ./shot.png --pr 123",
946
+ });
946
947
  }
947
948
  if (branchArg !== undefined) {
948
949
  if (parsed.flags.has("--pr"))
@@ -1523,8 +1524,9 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1523
1524
  }
1524
1525
  const files = parsed.positionals;
1525
1526
  if (files.length === 0) {
1526
- writeCommandHelp(PUT_HELP);
1527
- return 2;
1527
+ throw new UsageError("put requires at least one file", {
1528
+ example: "uploads put ./shot.png --pr 123",
1529
+ });
1528
1530
  }
1529
1531
  const multi = files.length > 1;
1530
1532
  const keyHint = flagString(parsed.flags, "--key");
@@ -1950,15 +1952,21 @@ function githubCoordinateFromFlags(flags) {
1950
1952
  export async function runGallery(ctx, args, help = false) {
1951
1953
  const parsed = parseCommandArgs(args);
1952
1954
  const action = parsed.positionals[0];
1953
- if (help || parsed.help || !action) {
1955
+ if (help || parsed.help) {
1954
1956
  writeCommandHelp(GALLERY_HELP);
1955
- return help || parsed.help ? 0 : 2;
1957
+ return 0;
1958
+ }
1959
+ if (!action) {
1960
+ throw new UsageError("gallery requires a subcommand: create, show, list, delete, add, link, or unlink", { example: 'uploads gallery create --title "Release screenshots"' });
1956
1961
  }
1957
1962
  switch (action) {
1958
1963
  case "create": {
1959
1964
  const title = flagString(parsed.flags, "--title");
1960
- if (!title)
1961
- throw new UsageError("gallery create requires --title");
1965
+ if (!title) {
1966
+ throw new UsageError("gallery create requires --title", {
1967
+ example: 'uploads gallery create --title "Release screenshots"',
1968
+ });
1969
+ }
1962
1970
  const gallery = await ctx.client.createGallery({
1963
1971
  title,
1964
1972
  description: flagString(parsed.flags, "--description"),
@@ -2122,7 +2130,7 @@ export async function runGallery(ctx, args, help = false) {
2122
2130
  return failures.length === 0 ? 0 : 1;
2123
2131
  }
2124
2132
  default:
2125
- throw new UsageError(`unknown gallery command: ${action}`);
2133
+ throw new UsageError(`unknown gallery command: ${action} (expected create, show, list, delete, add, link, or unlink)`, { example: 'uploads gallery create --title "Release screenshots"' });
2126
2134
  }
2127
2135
  }
2128
2136
  // --- list ---
@@ -2214,11 +2222,12 @@ export async function runList(ctx, args, help = false, run = execRunner) {
2214
2222
  const FIND_HELP = `uploads find k=v [k=v...] [--prefix <p>] [--limit <n>] [--workspace <name>]
2215
2223
 
2216
2224
  Human-friendly alias for \`uploads list --meta k=v...\` — same metadata filter
2217
- (ANDed equality), same output; pairs are positional instead of repeated flags.
2225
+ (ANDed equality), same output; pairs are positional, or spelled --meta k=v.
2218
2226
 
2219
2227
  Examples:
2220
2228
  uploads find gh.repo=buildinternet/uploads gh.number=123
2221
2229
  uploads find path=/settings state=after --prefix screenshots/
2230
+ uploads find --meta path=/settings
2222
2231
  `;
2223
2232
  export async function runFind(ctx, args, help = false) {
2224
2233
  const parsed = parseCommandArgs(args);
@@ -2226,11 +2235,15 @@ export async function runFind(ctx, args, help = false) {
2226
2235
  writeCommandHelp(FIND_HELP);
2227
2236
  return 0;
2228
2237
  }
2229
- if (parsed.positionals.length === 0) {
2230
- writeCommandHelp(FIND_HELP);
2231
- return 2;
2238
+ // Same flag/positional symmetry as `meta set` (issue #545): `find` is the
2239
+ // alias for `list --meta`, so `find --meta k=v` must not dead-end.
2240
+ const pairs = [...parsed.positionals, ...flagValues(parsed.flags, "--meta")];
2241
+ if (pairs.length === 0) {
2242
+ throw new UsageError("find requires at least one k=v pair (or --meta k=v)", {
2243
+ example: "uploads find path=/settings state=after",
2244
+ });
2232
2245
  }
2233
- const filters = parseMetaFlags(parsed.positionals);
2246
+ const filters = parseMetaFlags(pairs);
2234
2247
  return runFindFiles(ctx, filters, parsed.flags);
2235
2248
  }
2236
2249
  // --- meta ---
@@ -2243,23 +2256,35 @@ Commands:
2243
2256
  get <key> Show metadata for an object
2244
2257
  set <key> k=v [k=v...] [--delete k]... Merge-set and/or delete pairs
2245
2258
 
2259
+ Pairs take either form: positional k=v, or --meta k=v (same spelling as
2260
+ put/screenshot/list). Both can appear in one call.
2261
+
2246
2262
  Examples:
2247
2263
  uploads meta get screenshots/myapp/42/shot.png
2248
2264
  uploads meta set screenshots/myapp/42/shot.png path=/settings state=after
2265
+ uploads meta set screenshots/myapp/42/shot.png --meta path=/settings
2249
2266
  uploads meta set screenshots/myapp/42/shot.png --delete path --delete state
2250
2267
  `;
2251
2268
  export async function runMeta(ctx, args, help = false) {
2252
2269
  const parsed = parseCommandArgs(args);
2253
2270
  const action = parsed.positionals[0];
2254
- if (help || parsed.help || !action) {
2271
+ if (help || parsed.help) {
2255
2272
  writeCommandHelp(META_HELP);
2256
- return help || parsed.help ? 0 : 2;
2273
+ return 0;
2274
+ }
2275
+ if (!action) {
2276
+ throw new UsageError("meta requires a subcommand: get or set", {
2277
+ example: "uploads meta set screenshots/myapp/42/shot.png --meta path=/settings",
2278
+ });
2257
2279
  }
2258
2280
  switch (action) {
2259
2281
  case "get": {
2260
2282
  const key = parsed.positionals[1];
2261
- if (!key)
2262
- throw new UsageError("meta get requires an object key");
2283
+ if (!key) {
2284
+ throw new UsageError("meta get requires an object key", {
2285
+ example: "uploads meta get screenshots/myapp/42/shot.png",
2286
+ });
2287
+ }
2263
2288
  const result = await ctx.client.getMetadata(key);
2264
2289
  if (ctx.json)
2265
2290
  await writeJson(result);
@@ -2275,12 +2300,22 @@ export async function runMeta(ctx, args, help = false) {
2275
2300
  }
2276
2301
  case "set": {
2277
2302
  const key = parsed.positionals[1];
2278
- if (!key)
2279
- throw new UsageError("meta set requires an object key");
2280
- const pairs = parsed.positionals.slice(2);
2303
+ if (!key) {
2304
+ throw new UsageError("meta set requires an object key", {
2305
+ example: "uploads meta set screenshots/myapp/42/shot.png --meta path=/settings",
2306
+ });
2307
+ }
2308
+ // `--meta k=v` is accepted alongside the positional form: `put`, `list`,
2309
+ // and `screenshot` all spell metadata that way, and `put`'s own success
2310
+ // tip teaches the flag, so carrying it here is the natural guess
2311
+ // (issue #545). Positionals come first so argument order still reads
2312
+ // left to right when both are used.
2313
+ const pairs = [...parsed.positionals.slice(2), ...flagValues(parsed.flags, "--meta")];
2281
2314
  const del = flagValues(parsed.flags, "--delete");
2282
2315
  if (pairs.length === 0 && del.length === 0) {
2283
- throw new UsageError("meta set requires k=v pairs and/or --delete <key>");
2316
+ throw new UsageError("meta set requires k=v pairs and/or --delete <key>", {
2317
+ example: `uploads meta set ${key} --meta path=/settings`,
2318
+ });
2284
2319
  }
2285
2320
  const set = pairs.length > 0 ? parseMetaFlags(pairs) : undefined;
2286
2321
  const result = await ctx.client.patchMetadata(key, {
@@ -2296,7 +2331,9 @@ export async function runMeta(ctx, args, help = false) {
2296
2331
  return 0;
2297
2332
  }
2298
2333
  default:
2299
- throw new UsageError(`unknown meta command: ${action}`);
2334
+ throw new UsageError(`unknown meta command: ${action} (expected get or set)`, {
2335
+ example: "uploads meta get screenshots/myapp/42/shot.png",
2336
+ });
2300
2337
  }
2301
2338
  }
2302
2339
  /** The metadata keys the managed comment renders (path/state, PR #370). */
@@ -2356,8 +2393,9 @@ export async function runDelete(ctx, args, help = false) {
2356
2393
  }
2357
2394
  const key = parsed.positionals[0];
2358
2395
  if (!key) {
2359
- writeCommandHelp(DELETE_HELP);
2360
- return 2;
2396
+ throw new UsageError("delete requires an object key", {
2397
+ example: "uploads delete screenshots/myapp/42/shot.png --dry-run",
2398
+ });
2361
2399
  }
2362
2400
  if (flagBool(parsed.flags, "--dry-run")) {
2363
2401
  if (ctx.json)
@@ -2397,8 +2435,11 @@ export async function runComment(ctx, args, help = false, run = execRunner) {
2397
2435
  return 0;
2398
2436
  }
2399
2437
  const target = ghTargetFromFlags(parsed.flags, run);
2400
- if (!target)
2401
- throw new UsageError("comment requires --pr or --issue");
2438
+ if (!target) {
2439
+ throw new UsageError("comment requires --pr or --issue", {
2440
+ example: "uploads comment --pr 123",
2441
+ });
2442
+ }
2402
2443
  const result = await syncAttachmentsComment(ctx.client, target, run, ctx.config.workspace, {
2403
2444
  resync: true,
2404
2445
  });
@@ -2568,7 +2609,9 @@ export async function runGithub(ctx, args, help = false, run = execRunner) {
2568
2609
  return help || parsed.help ? 0 : 2;
2569
2610
  }
2570
2611
  if (action !== "link" && action !== "unlink" && action !== "doctor") {
2571
- throw new UsageError(`unknown github subcommand: ${action}`);
2612
+ throw new UsageError(`unknown github subcommand: ${action} (expected link or doctor)`, {
2613
+ example: "uploads github link",
2614
+ });
2572
2615
  }
2573
2616
  if (action === "doctor")
2574
2617
  return runGithubDoctor(ctx);
@@ -74,6 +74,13 @@ export declare function ghMetadataFromTargetWithTitle(target: GhTarget, run?: Co
74
74
  * deleted best-effort via `gh api -X DELETE`; a failed delete is swallowed
75
75
  * and never fails the caller's command, and the next sync retries anyway.
76
76
  *
77
+ * A create additionally re-hunts once it has written (issue #553, mirroring
78
+ * the bot path): find-or-create is not atomic, so a concurrent writer can
79
+ * create its own comment in the same window. Both writers independently agree
80
+ * the OLDEST marker comment wins, fold their body into it and delete the rest
81
+ * — including their own create — so the race converges instead of leaving a
82
+ * stale orphan behind for a PR that never syncs again.
83
+ *
77
84
  * On why this duplicates the bot path rather than deferring to it: the gh
78
85
  * fallback is a supported path, not a stopgap, so it is held at behavioral
79
86
  * parity deliberately. This file already reimplements the hunt, the legacy
package/dist/github-gh.js CHANGED
@@ -266,6 +266,13 @@ function findManagedComment(target, run, marker) {
266
266
  * deleted best-effort via `gh api -X DELETE`; a failed delete is swallowed
267
267
  * and never fails the caller's command, and the next sync retries anyway.
268
268
  *
269
+ * A create additionally re-hunts once it has written (issue #553, mirroring
270
+ * the bot path): find-or-create is not atomic, so a concurrent writer can
271
+ * create its own comment in the same window. Both writers independently agree
272
+ * the OLDEST marker comment wins, fold their body into it and delete the rest
273
+ * — including their own create — so the race converges instead of leaving a
274
+ * stale orphan behind for a PR that never syncs again.
275
+ *
269
276
  * On why this duplicates the bot path rather than deferring to it: the gh
270
277
  * fallback is a supported path, not a stopgap, so it is held at behavioral
271
278
  * parity deliberately. This file already reimplements the hunt, the legacy
@@ -281,26 +288,9 @@ function findManagedComment(target, run, marker) {
281
288
  export function upsertAttachmentsComment(target, body, run = execRunner, marker = ATTACHMENTS_MARKER, opts = {}) {
282
289
  const createIfMissing = opts.createIfMissing ?? true;
283
290
  const { comment: existing, extras } = findManagedComment(target, run, marker);
284
- const deleteExtras = () => {
285
- for (const extra of extras ?? []) {
286
- try {
287
- run("gh", ["api", `repos/${target.repo}/issues/comments/${extra.id}`, "-X", "DELETE"]);
288
- }
289
- catch {
290
- // Best effort only — a failed delete must never fail the caller's command.
291
- }
292
- }
293
- };
294
291
  if (existing) {
295
- run("gh", [
296
- "api",
297
- `repos/${target.repo}/issues/comments/${existing.id}`,
298
- "-X",
299
- "PATCH",
300
- "-F",
301
- "body=@-",
302
- ], body);
303
- deleteExtras();
292
+ patchComment(target, run, existing.id, body);
293
+ deleteComments(target, run, extras);
304
294
  return { action: "updated" };
305
295
  }
306
296
  // Patch-only (createIfMissing false, i.e. an empty body) with no existing
@@ -308,7 +298,65 @@ export function upsertAttachmentsComment(target, body, run = execRunner, marker
308
298
  if (!createIfMissing)
309
299
  return { action: "skipped" };
310
300
  // No existing marker hit means `extras` is necessarily empty here (see
311
- // `findManagedComment`) — nothing to delete after a create.
312
- run("gh", ["api", `repos/${target.repo}/issues/${target.num}/comments`, "-F", "body=@-"], body);
313
- return { action: "created" };
301
+ // `findManagedComment`) — nothing to delete before the create.
302
+ const created = run("gh", ["api", `repos/${target.repo}/issues/${target.num}/comments`, "-F", "body=@-"], body);
303
+ return reconcileAfterCreate(target, body, run, marker, created) ?? { action: "created" };
304
+ }
305
+ /**
306
+ * Re-hunt right after a create and collapse whatever a concurrent writer left
307
+ * behind (issue #553). Returns `{ action: "updated" }` when this run lost the
308
+ * race — its body has been folded into the older winning comment and its own
309
+ * create deleted — or null when nothing needed folding, including every
310
+ * failure: a verification problem must never fail a successful create.
311
+ *
312
+ * The winner is patched BEFORE any delete, so a failed fold leaves both
313
+ * comments (the next sync's hunt retries) rather than deleting the one that
314
+ * carries the current body.
315
+ */
316
+ function reconcileAfterCreate(target, body, run, marker, createdRaw) {
317
+ let createdId;
318
+ try {
319
+ createdId = JSON.parse(createdRaw).id;
320
+ }
321
+ catch {
322
+ // `gh` printed something unparseable — fall through to the hunt, which
323
+ // identifies the winner on its own.
324
+ }
325
+ try {
326
+ const { comment: winner, extras } = findManagedComment(target, run, marker);
327
+ // `extras` is undefined in legacy mode, where a second hit may belong to
328
+ // another workspace — the adopt-only contract holds here too.
329
+ if (!winner || !extras?.length)
330
+ return null;
331
+ if (winner.id === createdId) {
332
+ // Ours is the oldest and already carries the body we just wrote — only
333
+ // the other writer's duplicate needs to go.
334
+ deleteComments(target, run, extras);
335
+ return null;
336
+ }
337
+ patchComment(target, run, winner.id, body);
338
+ deleteComments(target, run, extras);
339
+ return { action: "updated" };
340
+ }
341
+ catch {
342
+ // A failed listing or fold leaves the freshly created comment in place —
343
+ // correct content, one duplicate, healed by the next sync's hunt.
344
+ return null;
345
+ }
346
+ }
347
+ /** PATCH one comment's body via stdin, so the body is never shell-interpolated. */
348
+ function patchComment(target, run, id, body) {
349
+ run("gh", ["api", `repos/${target.repo}/issues/comments/${id}`, "-X", "PATCH", "-F", "body=@-"], body);
350
+ }
351
+ /** Best-effort delete: a failed delete must never fail the caller's command,
352
+ * and the next sync's hunt retries anyway. */
353
+ function deleteComments(target, run, comments) {
354
+ for (const c of comments ?? []) {
355
+ try {
356
+ run("gh", ["api", `repos/${target.repo}/issues/comments/${c.id}`, "-X", "DELETE"]);
357
+ }
358
+ catch {
359
+ // Best effort only.
360
+ }
361
+ }
314
362
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buildinternet/uploads",
3
- "version": "0.34.1",
3
+ "version": "0.35.1",
4
4
  "description": "CLI and client for uploads.sh — workspace-scoped image hosting for GitHub embeds",
5
5
  "type": "module",
6
6
  "sideEffects": false,