@buildinternet/uploads 0.1.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.
- package/README.md +64 -0
- package/bin/uploads.js +9 -0
- package/dist/agent.d.ts +8 -0
- package/dist/agent.js +24 -0
- package/dist/cli-args.d.ts +39 -0
- package/dist/cli-args.js +129 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +158 -0
- package/dist/client.d.ts +55 -0
- package/dist/client.js +97 -0
- package/dist/commands/config.d.ts +4 -0
- package/dist/commands/config.js +202 -0
- package/dist/commands/setup.d.ts +4 -0
- package/dist/commands/setup.js +223 -0
- package/dist/commands.d.ts +19 -0
- package/dist/commands.js +451 -0
- package/dist/config-file.d.ts +37 -0
- package/dist/config-file.js +204 -0
- package/dist/config.d.ts +37 -0
- package/dist/config.js +157 -0
- package/dist/embed.d.ts +6 -0
- package/dist/embed.js +30 -0
- package/dist/errors.d.ts +6 -0
- package/dist/errors.js +10 -0
- package/dist/github-gh.d.ts +19 -0
- package/dist/github-gh.js +93 -0
- package/dist/github.d.ts +24 -0
- package/dist/github.js +44 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/keys.d.ts +11 -0
- package/dist/keys.js +34 -0
- package/package.json +60 -0
package/dist/commands.js
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import { createUploadsClient } from "./client.js";
|
|
4
|
+
import { parseCommandArgs, flagString, flagBool, flagInt, UsageError, } from "./cli-args.js";
|
|
5
|
+
import { resolvePutDefaults, workspaceMismatch, workspaceFromToken, } from "./config.js";
|
|
6
|
+
import { buildMarkdown } from "./embed.js";
|
|
7
|
+
import { UploadsError } from "./errors.js";
|
|
8
|
+
import { ghAttachmentKey, ghKeyPrefix, attachmentsCommentBody, } from "./github.js";
|
|
9
|
+
import { resolveRepo, resolveCurrentPullRequest, execRunner, upsertAttachmentsComment, } from "./github-gh.js";
|
|
10
|
+
async function writeStdout(text) {
|
|
11
|
+
if (!process.stdout.write(text)) {
|
|
12
|
+
await new Promise((resolve) => process.stdout.once("drain", resolve));
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
async function writeJson(value) {
|
|
16
|
+
await writeStdout(JSON.stringify(value, null, 2) + "\n");
|
|
17
|
+
}
|
|
18
|
+
// --- put ---
|
|
19
|
+
const PUT_HELP = `uploads put <file> [options]
|
|
20
|
+
|
|
21
|
+
Upload an image for GitHub embeds. Use "-" for stdin.
|
|
22
|
+
|
|
23
|
+
Options:
|
|
24
|
+
--key <key> Object key (default: <prefix>/<repo>/<ref>/<name>-<hash>.<ext>)
|
|
25
|
+
--prefix <path> Key prefix (default: screenshots, or UPLOADS_DEFAULT_PREFIX)
|
|
26
|
+
--repo <owner/repo> Repo segment (default: git remote, or UPLOADS_DEFAULT_REPO)
|
|
27
|
+
--ref <id> PR/issue/branch segment (default: today, or UPLOADS_DEFAULT_REF)
|
|
28
|
+
--alt <text> Alt text (default: filename)
|
|
29
|
+
--width <px> <img width=…> markdown (or UPLOADS_DEFAULT_WIDTH)
|
|
30
|
+
--content-type <mime> Override Content-Type
|
|
31
|
+
--no-git Don't derive --repo from git (or UPLOADS_NO_GIT=1)
|
|
32
|
+
--workspace, -w <name> Override workspace (wins over UPLOADS_WORKSPACE and token inference)
|
|
33
|
+
--format human|url|markdown|json
|
|
34
|
+
--pr <num> Attach to a pull request: key gh/<owner>/<repo>/pull/<num>/<name> (stable URL, no hash)
|
|
35
|
+
--issue <num> Attach to an issue: key gh/<owner>/<repo>/issues/<num>/<name>
|
|
36
|
+
--comment With --pr/--issue: create/update the attachments comment via your local gh auth
|
|
37
|
+
|
|
38
|
+
Examples:
|
|
39
|
+
uploads put ./shot.png --repo myorg/myapp --ref 1722 --alt "New cards" --width 700
|
|
40
|
+
uploads --env-file .env put ./shot.png
|
|
41
|
+
uploads --env-file .env put ./after.png --pr 123 --comment
|
|
42
|
+
`;
|
|
43
|
+
/** Reads --pr/--issue (+ --repo) into a GhTarget; undefined when neither flag is present. */
|
|
44
|
+
function ghTargetFromFlags(flags, run) {
|
|
45
|
+
const pr = flagInt(flags, "--pr", "--pr");
|
|
46
|
+
const issue = flagInt(flags, "--issue", "--issue");
|
|
47
|
+
if (pr === undefined && issue === undefined)
|
|
48
|
+
return undefined;
|
|
49
|
+
if (pr !== undefined && issue !== undefined) {
|
|
50
|
+
throw new UsageError("--pr and --issue are mutually exclusive");
|
|
51
|
+
}
|
|
52
|
+
const repo = resolveRepo(flagString(flags, "--repo"), run);
|
|
53
|
+
return { repo, kind: pr !== undefined ? "pull" : "issues", num: (pr ?? issue) };
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* List every attachment under the target's prefix and create/update the
|
|
57
|
+
* managed comment. Throws on gh failure — callers decide whether that is
|
|
58
|
+
* fatal (`comment` command) or a warning (`put --comment`).
|
|
59
|
+
*/
|
|
60
|
+
async function syncAttachmentsComment(ctx, target, run) {
|
|
61
|
+
const items = [];
|
|
62
|
+
let cursor;
|
|
63
|
+
do {
|
|
64
|
+
const page = await ctx.client.list({ prefix: ghKeyPrefix(target), cursor });
|
|
65
|
+
items.push(...page.items.map(({ key, url }) => ({ key, url })));
|
|
66
|
+
cursor = page.cursor ?? undefined;
|
|
67
|
+
} while (cursor);
|
|
68
|
+
if (items.length === 0)
|
|
69
|
+
return { action: "skipped", count: 0 };
|
|
70
|
+
const body = attachmentsCommentBody(items);
|
|
71
|
+
const { created } = upsertAttachmentsComment(target, body, run);
|
|
72
|
+
return { action: created ? "created" : "updated", count: items.length };
|
|
73
|
+
}
|
|
74
|
+
// --- attach ---
|
|
75
|
+
const ATTACH_HELP = `uploads attach <file...> [options]
|
|
76
|
+
|
|
77
|
+
Upload one or more stable PR/issue attachments and maintain a single GitHub
|
|
78
|
+
comment. With no target, uses the pull request for the current branch.
|
|
79
|
+
|
|
80
|
+
Options:
|
|
81
|
+
--pr <num> Attach to this pull request
|
|
82
|
+
--issue <num> Attach to this issue
|
|
83
|
+
--repo <owner/repo> Repository (default: gh/git inference)
|
|
84
|
+
--no-comment Upload only; don't create/update the managed comment
|
|
85
|
+
--content-type <mime> Override Content-Type (applied to every file)
|
|
86
|
+
--workspace, -w <name> Override workspace
|
|
87
|
+
|
|
88
|
+
Examples:
|
|
89
|
+
uploads attach ./before.png ./after.png
|
|
90
|
+
uploads attach ./shot.png --pr 123 --repo myorg/myapp
|
|
91
|
+
uploads attach ./artifact.zip --issue 45 --no-comment
|
|
92
|
+
`;
|
|
93
|
+
export async function runAttach(ctx, args, help = false, run = execRunner) {
|
|
94
|
+
const parsed = parseCommandArgs(args);
|
|
95
|
+
if (help || parsed.help) {
|
|
96
|
+
process.stderr.write(ATTACH_HELP);
|
|
97
|
+
return 0;
|
|
98
|
+
}
|
|
99
|
+
if (parsed.positionals.length === 0) {
|
|
100
|
+
process.stderr.write(ATTACH_HELP);
|
|
101
|
+
return 2;
|
|
102
|
+
}
|
|
103
|
+
if (parsed.flags.has("--no-comment") && typeof parsed.flags.get("--no-comment") === "string") {
|
|
104
|
+
throw new UsageError("--no-comment takes no value — place it after the file arguments");
|
|
105
|
+
}
|
|
106
|
+
const explicitTarget = ghTargetFromFlags(parsed.flags, run);
|
|
107
|
+
const target = explicitTarget ??
|
|
108
|
+
resolveCurrentPullRequest(resolveRepo(flagString(parsed.flags, "--repo"), run), run);
|
|
109
|
+
const results = [];
|
|
110
|
+
for (const file of parsed.positionals) {
|
|
111
|
+
if (file === "-")
|
|
112
|
+
throw new UsageError("attach does not support stdin; pass one or more file paths");
|
|
113
|
+
const filename = basename(file);
|
|
114
|
+
if (!ctx.quiet && !ctx.json)
|
|
115
|
+
process.stderr.write(`>> uploading ${file}\n`);
|
|
116
|
+
const result = await ctx.client.put(new Uint8Array(readFileSync(file)), {
|
|
117
|
+
filename,
|
|
118
|
+
key: ghAttachmentKey(target, filename),
|
|
119
|
+
contentType: flagString(parsed.flags, "--content-type"),
|
|
120
|
+
});
|
|
121
|
+
results.push({ ...result, markdown: buildMarkdown(result.url, { alt: filename }) });
|
|
122
|
+
}
|
|
123
|
+
let comment;
|
|
124
|
+
let commentError;
|
|
125
|
+
if (!parsed.flags.has("--no-comment")) {
|
|
126
|
+
try {
|
|
127
|
+
comment = await syncAttachmentsComment(ctx, target, run);
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
commentError = err instanceof Error ? err.message : String(err);
|
|
131
|
+
process.stderr.write(`warning: uploads succeeded but the GitHub comment failed (is gh installed and authenticated?): ${commentError}\n`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (ctx.json) {
|
|
135
|
+
await writeJson({ target, uploads: results, comment, commentError });
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
for (const result of results) {
|
|
139
|
+
await writeStdout(`URL: ${result.url}\nMARKDOWN: ${result.markdown}\n`);
|
|
140
|
+
}
|
|
141
|
+
if (!ctx.quiet && comment)
|
|
142
|
+
process.stderr.write(`>> attachments comment ${comment.action}\n`);
|
|
143
|
+
}
|
|
144
|
+
return 0;
|
|
145
|
+
}
|
|
146
|
+
export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
147
|
+
if (help) {
|
|
148
|
+
process.stderr.write(PUT_HELP);
|
|
149
|
+
return 0;
|
|
150
|
+
}
|
|
151
|
+
const parsed = parseCommandArgs(args);
|
|
152
|
+
if (parsed.help) {
|
|
153
|
+
process.stderr.write(PUT_HELP);
|
|
154
|
+
return 0;
|
|
155
|
+
}
|
|
156
|
+
const fileArg = parsed.positionals[0];
|
|
157
|
+
if (!fileArg) {
|
|
158
|
+
process.stderr.write(PUT_HELP);
|
|
159
|
+
return 2;
|
|
160
|
+
}
|
|
161
|
+
const keyHint = flagString(parsed.flags, "--key");
|
|
162
|
+
const ghTarget = ghTargetFromFlags(parsed.flags, run);
|
|
163
|
+
const wantComment = parsed.flags.has("--comment");
|
|
164
|
+
if (wantComment && typeof parsed.flags.get("--comment") === "string") {
|
|
165
|
+
throw new UsageError("--comment takes no value — place it after the file argument");
|
|
166
|
+
}
|
|
167
|
+
if (wantComment && !ghTarget)
|
|
168
|
+
throw new UsageError("--comment requires --pr or --issue");
|
|
169
|
+
if (ghTarget) {
|
|
170
|
+
if (keyHint)
|
|
171
|
+
throw new UsageError("--key cannot be combined with --pr/--issue");
|
|
172
|
+
if (flagString(parsed.flags, "--ref")) {
|
|
173
|
+
throw new UsageError("--ref cannot be combined with --pr/--issue");
|
|
174
|
+
}
|
|
175
|
+
if (flagString(parsed.flags, "--prefix")) {
|
|
176
|
+
throw new UsageError("--prefix cannot be combined with --pr/--issue");
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const bytes = fileArg === "-" ? new Uint8Array(readFileSync(0)) : new Uint8Array(readFileSync(fileArg));
|
|
180
|
+
const filename = fileArg === "-" ? (keyHint ? basename(keyHint) : "stdin.bin") : basename(fileArg);
|
|
181
|
+
const format = ctx.json
|
|
182
|
+
? "json"
|
|
183
|
+
: (() => {
|
|
184
|
+
const raw = flagString(parsed.flags, "--format");
|
|
185
|
+
if (!raw || raw === "human")
|
|
186
|
+
return "human";
|
|
187
|
+
if (raw === "url" || raw === "markdown" || raw === "json")
|
|
188
|
+
return raw;
|
|
189
|
+
throw new UsageError(`invalid --format: ${raw}`);
|
|
190
|
+
})();
|
|
191
|
+
const defaults = resolvePutDefaults({ envFile: ctx.envFile });
|
|
192
|
+
const alt = flagString(parsed.flags, "--alt") ?? basename(filename);
|
|
193
|
+
const widthRaw = flagString(parsed.flags, "--width");
|
|
194
|
+
const width = widthRaw && /^\d+$/.test(widthRaw) && Number(widthRaw) > 0
|
|
195
|
+
? Number.parseInt(widthRaw, 10)
|
|
196
|
+
: widthRaw
|
|
197
|
+
? (() => {
|
|
198
|
+
throw new UsageError(`invalid --width: ${widthRaw}`);
|
|
199
|
+
})()
|
|
200
|
+
: defaults.width;
|
|
201
|
+
if (!ctx.quiet && format === "human") {
|
|
202
|
+
process.stderr.write(`>> uploading ${fileArg === "-" ? "stdin" : fileArg}\n`);
|
|
203
|
+
}
|
|
204
|
+
const noGit = flagBool(parsed.flags, "--no-git") || defaults.noGit === true;
|
|
205
|
+
const result = await ctx.client.put(bytes, {
|
|
206
|
+
filename,
|
|
207
|
+
key: ghTarget ? ghAttachmentKey(ghTarget, filename) : keyHint,
|
|
208
|
+
prefix: flagString(parsed.flags, "--prefix") ?? defaults.prefix,
|
|
209
|
+
repo: flagString(parsed.flags, "--repo") ?? defaults.repo,
|
|
210
|
+
ref: flagString(parsed.flags, "--ref") ?? defaults.ref,
|
|
211
|
+
contentType: flagString(parsed.flags, "--content-type"),
|
|
212
|
+
deriveRepoFromGit: !noGit,
|
|
213
|
+
});
|
|
214
|
+
const markdown = buildMarkdown(result.url, { alt, width });
|
|
215
|
+
if (!ctx.quiet && format === "human") {
|
|
216
|
+
process.stderr.write(`>> key: ${result.key}\n\n`);
|
|
217
|
+
}
|
|
218
|
+
switch (format) {
|
|
219
|
+
case "json":
|
|
220
|
+
await writeJson({ ...result, markdown });
|
|
221
|
+
break;
|
|
222
|
+
case "url":
|
|
223
|
+
await writeStdout(`${result.url}\n`);
|
|
224
|
+
break;
|
|
225
|
+
case "markdown":
|
|
226
|
+
await writeStdout(`${markdown}\n`);
|
|
227
|
+
break;
|
|
228
|
+
default:
|
|
229
|
+
await writeStdout(`URL: ${result.url}\nMARKDOWN: ${markdown}\n`);
|
|
230
|
+
}
|
|
231
|
+
if (wantComment && ghTarget) {
|
|
232
|
+
try {
|
|
233
|
+
const sync = await syncAttachmentsComment(ctx, ghTarget, run);
|
|
234
|
+
if (!ctx.quiet && format === "human") {
|
|
235
|
+
process.stderr.write(`>> attachments comment ${sync.action}\n`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
catch (err) {
|
|
239
|
+
// Upload already succeeded; the comment is best-effort by design.
|
|
240
|
+
process.stderr.write(`warning: upload succeeded but the GitHub comment failed (is gh installed and authenticated?): ${err instanceof Error ? err.message : String(err)}\n`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return 0;
|
|
244
|
+
}
|
|
245
|
+
// --- list ---
|
|
246
|
+
const LIST_HELP = `uploads list [--prefix <p>] [--pr <num> | --issue <num>] [--repo <owner/name>] [--limit <n>] [--cursor <c>] [--all] [--workspace <name>]
|
|
247
|
+
|
|
248
|
+
Default prefix: UPLOADS_DEFAULT_PREFIX (screenshots if unset).
|
|
249
|
+
|
|
250
|
+
Examples:
|
|
251
|
+
uploads list --prefix screenshots/
|
|
252
|
+
uploads list --pr 123
|
|
253
|
+
uploads list --all --json
|
|
254
|
+
`;
|
|
255
|
+
export async function runList(ctx, args, help = false, run = execRunner) {
|
|
256
|
+
const parsed = parseCommandArgs(args);
|
|
257
|
+
if (help || parsed.help) {
|
|
258
|
+
process.stderr.write(LIST_HELP);
|
|
259
|
+
return 0;
|
|
260
|
+
}
|
|
261
|
+
const defaults = resolvePutDefaults({ envFile: ctx.envFile });
|
|
262
|
+
const prefixFlag = flagString(parsed.flags, "--prefix");
|
|
263
|
+
let prefix = prefixFlag ?? (defaults.prefix ? `${defaults.prefix}/` : undefined);
|
|
264
|
+
const ghTarget = ghTargetFromFlags(parsed.flags, run);
|
|
265
|
+
if (ghTarget) {
|
|
266
|
+
if (prefixFlag)
|
|
267
|
+
throw new UsageError("--prefix cannot be combined with --pr/--issue");
|
|
268
|
+
prefix = ghKeyPrefix(ghTarget);
|
|
269
|
+
}
|
|
270
|
+
const limit = flagInt(parsed.flags, "--limit", "--limit");
|
|
271
|
+
const cursor = flagString(parsed.flags, "--cursor");
|
|
272
|
+
if (flagBool(parsed.flags, "--all")) {
|
|
273
|
+
const items = [];
|
|
274
|
+
let next = cursor;
|
|
275
|
+
do {
|
|
276
|
+
const page = await ctx.client.list({ prefix, limit, cursor: next ?? undefined });
|
|
277
|
+
items.push(...page.items);
|
|
278
|
+
next = page.cursor;
|
|
279
|
+
} while (next);
|
|
280
|
+
if (ctx.json)
|
|
281
|
+
await writeJson({ items, cursor: null });
|
|
282
|
+
else
|
|
283
|
+
for (const item of items)
|
|
284
|
+
await writeStdout(`${item.key}${item.url ? ` ${item.url}` : ""}\n`);
|
|
285
|
+
return 0;
|
|
286
|
+
}
|
|
287
|
+
const result = await ctx.client.list({ prefix, limit, cursor });
|
|
288
|
+
if (ctx.json)
|
|
289
|
+
await writeJson(result);
|
|
290
|
+
else {
|
|
291
|
+
for (const item of result.items)
|
|
292
|
+
await writeStdout(`${item.key}${item.url ? ` ${item.url}` : ""}\n`);
|
|
293
|
+
if (result.cursor)
|
|
294
|
+
process.stderr.write(`cursor: ${result.cursor}\n`);
|
|
295
|
+
}
|
|
296
|
+
return 0;
|
|
297
|
+
}
|
|
298
|
+
// --- delete ---
|
|
299
|
+
const DELETE_HELP = `uploads delete <key> [--dry-run] [--workspace <name>]
|
|
300
|
+
|
|
301
|
+
Examples:
|
|
302
|
+
uploads delete screenshots/myapp/42/shot-a1b2c3.png
|
|
303
|
+
`;
|
|
304
|
+
export async function runDelete(ctx, args, help = false) {
|
|
305
|
+
const parsed = parseCommandArgs(args);
|
|
306
|
+
if (help || parsed.help) {
|
|
307
|
+
process.stderr.write(DELETE_HELP);
|
|
308
|
+
return 0;
|
|
309
|
+
}
|
|
310
|
+
const key = parsed.positionals[0];
|
|
311
|
+
if (!key) {
|
|
312
|
+
process.stderr.write(DELETE_HELP);
|
|
313
|
+
return 2;
|
|
314
|
+
}
|
|
315
|
+
if (flagBool(parsed.flags, "--dry-run")) {
|
|
316
|
+
if (ctx.json)
|
|
317
|
+
await writeJson({ key, deleted: false, dryRun: true });
|
|
318
|
+
else
|
|
319
|
+
process.stderr.write(`dry-run: would delete ${key}\n`);
|
|
320
|
+
return 0;
|
|
321
|
+
}
|
|
322
|
+
const result = await ctx.client.delete(key);
|
|
323
|
+
if (ctx.json)
|
|
324
|
+
await writeJson(result);
|
|
325
|
+
else if (!ctx.quiet)
|
|
326
|
+
process.stderr.write(`deleted ${result.key}\n`);
|
|
327
|
+
return 0;
|
|
328
|
+
}
|
|
329
|
+
// --- comment ---
|
|
330
|
+
const COMMENT_HELP = `uploads comment (--pr <num> | --issue <num>) [--repo <owner/name>] [--workspace <name>]
|
|
331
|
+
|
|
332
|
+
Create or update the managed attachments comment on a GitHub PR or issue,
|
|
333
|
+
listing everything uploaded for it. Uses your local gh auth. Finds its own
|
|
334
|
+
prior comment via a hidden marker and edits it in place; never touches other
|
|
335
|
+
comments or the description.
|
|
336
|
+
|
|
337
|
+
Examples:
|
|
338
|
+
uploads --env-file .env comment --pr 123
|
|
339
|
+
uploads comment --issue 45 --repo buildinternet/uploads
|
|
340
|
+
`;
|
|
341
|
+
export async function runComment(ctx, args, help = false, run = execRunner) {
|
|
342
|
+
const parsed = parseCommandArgs(args);
|
|
343
|
+
if (help || parsed.help) {
|
|
344
|
+
process.stderr.write(COMMENT_HELP);
|
|
345
|
+
return 0;
|
|
346
|
+
}
|
|
347
|
+
const target = ghTargetFromFlags(parsed.flags, run);
|
|
348
|
+
if (!target)
|
|
349
|
+
throw new UsageError("comment requires --pr or --issue");
|
|
350
|
+
const result = await syncAttachmentsComment(ctx, target, run);
|
|
351
|
+
if (ctx.json) {
|
|
352
|
+
await writeJson({ ...target, ...result });
|
|
353
|
+
}
|
|
354
|
+
else if (!ctx.quiet) {
|
|
355
|
+
process.stderr.write(result.action === "skipped"
|
|
356
|
+
? `no attachments under ${ghKeyPrefix(target)} — nothing to do\n`
|
|
357
|
+
: `${result.action} attachments comment on ${target.repo}#${target.num} (${result.count} file${result.count === 1 ? "" : "s"})\n`);
|
|
358
|
+
}
|
|
359
|
+
return 0;
|
|
360
|
+
}
|
|
361
|
+
// --- health & doctor ---
|
|
362
|
+
const HEALTH_HELP = `uploads health
|
|
363
|
+
|
|
364
|
+
API liveness (no auth).
|
|
365
|
+
|
|
366
|
+
Examples:
|
|
367
|
+
uploads health
|
|
368
|
+
uploads --api-url http://localhost:8787 health
|
|
369
|
+
`;
|
|
370
|
+
export async function runHealth(ctx, args, help = false) {
|
|
371
|
+
if (help || parseCommandArgs(args).help) {
|
|
372
|
+
process.stderr.write(HEALTH_HELP);
|
|
373
|
+
return 0;
|
|
374
|
+
}
|
|
375
|
+
const result = await createUploadsClient({
|
|
376
|
+
apiUrl: ctx.apiUrl,
|
|
377
|
+
workspace: "default",
|
|
378
|
+
token: "",
|
|
379
|
+
}).health();
|
|
380
|
+
if (ctx.json)
|
|
381
|
+
await writeJson({ ...result, apiUrl: ctx.apiUrl });
|
|
382
|
+
else
|
|
383
|
+
await writeStdout(result.ok ? `ok (${ctx.apiUrl})\n` : `unhealthy (${ctx.apiUrl})\n`);
|
|
384
|
+
return result.ok ? 0 : 1;
|
|
385
|
+
}
|
|
386
|
+
const DOCTOR_HELP = `uploads doctor [--workspace <name>]
|
|
387
|
+
|
|
388
|
+
Checks API health, token auth, and workspace/token alignment.
|
|
389
|
+
|
|
390
|
+
Examples:
|
|
391
|
+
uploads --env-file .env doctor
|
|
392
|
+
uploads --workspace acme --env-file .env doctor
|
|
393
|
+
`;
|
|
394
|
+
export async function runDoctor(ctx, args, help = false) {
|
|
395
|
+
if (help || parseCommandArgs(args).help) {
|
|
396
|
+
process.stderr.write(DOCTOR_HELP);
|
|
397
|
+
return 0;
|
|
398
|
+
}
|
|
399
|
+
const mismatch = workspaceMismatch(ctx.config);
|
|
400
|
+
const hints = [];
|
|
401
|
+
if (mismatch)
|
|
402
|
+
hints.push(mismatch);
|
|
403
|
+
if (ctx.config.apiUrl.includes("localhost") || ctx.config.apiUrl.includes("127.0.0.1")) {
|
|
404
|
+
hints.push("local API uses dev KV — prod tokens won't work unless minted with --local");
|
|
405
|
+
}
|
|
406
|
+
const health = await ctx.client.health();
|
|
407
|
+
let authOk = false;
|
|
408
|
+
let authError;
|
|
409
|
+
try {
|
|
410
|
+
await ctx.client.list({ limit: 1 });
|
|
411
|
+
authOk = true;
|
|
412
|
+
}
|
|
413
|
+
catch (err) {
|
|
414
|
+
authError = err instanceof UploadsError ? err.message : String(err);
|
|
415
|
+
if (err instanceof UploadsError && err.code === "UNAUTHORIZED") {
|
|
416
|
+
hints.push("if this token works on api.uploads.sh, set UPLOADS_API_URL=https://api.uploads.sh");
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
if (!ctx.config.configExists && !ctx.config.token) {
|
|
420
|
+
hints.push(`run uploads setup to configure ${ctx.config.configPath}`);
|
|
421
|
+
}
|
|
422
|
+
const report = {
|
|
423
|
+
ok: health.ok && authOk,
|
|
424
|
+
apiUrl: ctx.config.apiUrl,
|
|
425
|
+
workspace: ctx.config.workspace,
|
|
426
|
+
workspaceSource: ctx.config.workspaceSource,
|
|
427
|
+
workspaceFromToken: workspaceFromToken(ctx.config.token),
|
|
428
|
+
configPath: ctx.config.configPath,
|
|
429
|
+
configExists: ctx.config.configExists,
|
|
430
|
+
health,
|
|
431
|
+
auth: { ok: authOk, error: authError },
|
|
432
|
+
hints,
|
|
433
|
+
};
|
|
434
|
+
if (ctx.json) {
|
|
435
|
+
await writeJson(report);
|
|
436
|
+
return report.ok ? 0 : 1;
|
|
437
|
+
}
|
|
438
|
+
const lines = [
|
|
439
|
+
`config: ${ctx.config.configPath}${ctx.config.configExists ? "" : " (missing)"}`,
|
|
440
|
+
`api: ${ctx.config.apiUrl} (${health.ok ? "ok" : "failed"})`,
|
|
441
|
+
`workspace: ${ctx.config.workspace}`,
|
|
442
|
+
`auth: ${authOk ? "ok" : `failed — ${authError ?? "no token"}`}`,
|
|
443
|
+
];
|
|
444
|
+
if (mismatch)
|
|
445
|
+
lines.push(`warning: ${mismatch}`);
|
|
446
|
+
for (const h of hints)
|
|
447
|
+
if (h !== mismatch)
|
|
448
|
+
lines.push(`hint: ${h}`);
|
|
449
|
+
await writeStdout(lines.join("\n") + "\n");
|
|
450
|
+
return report.ok ? 0 : 1;
|
|
451
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { UploadsClientConfig } from "./config.js";
|
|
2
|
+
export declare const UPLOADS_CONFIG_KEYS: readonly ["UPLOADS_API_URL", "UPLOADS_WORKSPACE", "UPLOADS_TOKEN", "UPLOADS_DEFAULT_PREFIX", "UPLOADS_DEFAULT_REPO", "UPLOADS_DEFAULT_REF", "UPLOADS_DEFAULT_WIDTH", "UPLOADS_NO_GIT"];
|
|
3
|
+
export type UploadsConfigKey = (typeof UPLOADS_CONFIG_KEYS)[number];
|
|
4
|
+
export type UploadsConfigValues = Partial<Record<UploadsConfigKey, string>>;
|
|
5
|
+
export interface PutDefaults {
|
|
6
|
+
prefix?: string;
|
|
7
|
+
repo?: string;
|
|
8
|
+
ref?: string;
|
|
9
|
+
width?: number;
|
|
10
|
+
noGit?: boolean;
|
|
11
|
+
}
|
|
12
|
+
declare const PUT_DEFAULT_KEY_MAP: Record<keyof PutDefaults, UploadsConfigKey>;
|
|
13
|
+
export declare function putDefaultsToConfigValues(defaults: PutDefaults): UploadsConfigValues;
|
|
14
|
+
/** XDG default shared across buildinternet skills (github-screenshots, uploads, …). */
|
|
15
|
+
export declare function defaultConfigPath(): string;
|
|
16
|
+
/** Resolved config file path: explicit --env-file, then $BUILDINTERNET_CONFIG, then XDG default. */
|
|
17
|
+
export declare function resolveConfigPath(flags?: {
|
|
18
|
+
envFile?: string;
|
|
19
|
+
}): string;
|
|
20
|
+
/** Parse UPLOADS_* keys from a dotenv-style file. Missing file → empty object. */
|
|
21
|
+
export declare function loadConfigFile(path: string): UploadsConfigValues;
|
|
22
|
+
export declare function mergePutDefaults(...layers: PutDefaults[]): PutDefaults;
|
|
23
|
+
/** Put defaults from env, optional env-file, and user config (same precedence as client config). */
|
|
24
|
+
export declare function resolvePutDefaults(flags?: {
|
|
25
|
+
envFile?: string;
|
|
26
|
+
}): PutDefaults;
|
|
27
|
+
export declare function redactToken(token: string | undefined): string;
|
|
28
|
+
/** Create or update UPLOADS_* keys in the shared config file. Preserves other keys. */
|
|
29
|
+
export declare function writeConfigKeys(path: string, keys: UploadsConfigValues, opts?: {
|
|
30
|
+
force?: boolean;
|
|
31
|
+
}): {
|
|
32
|
+
path: string;
|
|
33
|
+
created: boolean;
|
|
34
|
+
updated: string[];
|
|
35
|
+
};
|
|
36
|
+
export declare function configValuesFromClient(config: Partial<UploadsClientConfig>, defaults?: PutDefaults): UploadsConfigValues;
|
|
37
|
+
export { PUT_DEFAULT_KEY_MAP };
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
export const UPLOADS_CONFIG_KEYS = [
|
|
5
|
+
"UPLOADS_API_URL",
|
|
6
|
+
"UPLOADS_WORKSPACE",
|
|
7
|
+
"UPLOADS_TOKEN",
|
|
8
|
+
"UPLOADS_DEFAULT_PREFIX",
|
|
9
|
+
"UPLOADS_DEFAULT_REPO",
|
|
10
|
+
"UPLOADS_DEFAULT_REF",
|
|
11
|
+
"UPLOADS_DEFAULT_WIDTH",
|
|
12
|
+
"UPLOADS_NO_GIT",
|
|
13
|
+
];
|
|
14
|
+
const PUT_DEFAULT_KEY_MAP = {
|
|
15
|
+
prefix: "UPLOADS_DEFAULT_PREFIX",
|
|
16
|
+
repo: "UPLOADS_DEFAULT_REPO",
|
|
17
|
+
ref: "UPLOADS_DEFAULT_REF",
|
|
18
|
+
width: "UPLOADS_DEFAULT_WIDTH",
|
|
19
|
+
noGit: "UPLOADS_NO_GIT",
|
|
20
|
+
};
|
|
21
|
+
export function putDefaultsToConfigValues(defaults) {
|
|
22
|
+
const out = {};
|
|
23
|
+
if (defaults.prefix)
|
|
24
|
+
out.UPLOADS_DEFAULT_PREFIX = defaults.prefix;
|
|
25
|
+
if (defaults.repo)
|
|
26
|
+
out.UPLOADS_DEFAULT_REPO = defaults.repo;
|
|
27
|
+
if (defaults.ref)
|
|
28
|
+
out.UPLOADS_DEFAULT_REF = defaults.ref;
|
|
29
|
+
if (defaults.width != null)
|
|
30
|
+
out.UPLOADS_DEFAULT_WIDTH = String(defaults.width);
|
|
31
|
+
if (defaults.noGit)
|
|
32
|
+
out.UPLOADS_NO_GIT = "1";
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
function parsePutDefaultsFromRaw(raw) {
|
|
36
|
+
const out = {};
|
|
37
|
+
if (raw.UPLOADS_DEFAULT_PREFIX)
|
|
38
|
+
out.prefix = raw.UPLOADS_DEFAULT_PREFIX;
|
|
39
|
+
if (raw.UPLOADS_DEFAULT_REPO)
|
|
40
|
+
out.repo = raw.UPLOADS_DEFAULT_REPO;
|
|
41
|
+
if (raw.UPLOADS_DEFAULT_REF)
|
|
42
|
+
out.ref = raw.UPLOADS_DEFAULT_REF;
|
|
43
|
+
if (raw.UPLOADS_DEFAULT_WIDTH) {
|
|
44
|
+
const n = Number.parseInt(raw.UPLOADS_DEFAULT_WIDTH, 10);
|
|
45
|
+
if (Number.isFinite(n) && n > 0)
|
|
46
|
+
out.width = n;
|
|
47
|
+
}
|
|
48
|
+
if (raw.UPLOADS_NO_GIT === "1" || raw.UPLOADS_NO_GIT?.toLowerCase() === "true") {
|
|
49
|
+
out.noGit = true;
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
function parsePutDefaultsFromEnv() {
|
|
54
|
+
const raw = {};
|
|
55
|
+
if (process.env.UPLOADS_DEFAULT_PREFIX)
|
|
56
|
+
raw.UPLOADS_DEFAULT_PREFIX = process.env.UPLOADS_DEFAULT_PREFIX;
|
|
57
|
+
if (process.env.UPLOADS_DEFAULT_REPO)
|
|
58
|
+
raw.UPLOADS_DEFAULT_REPO = process.env.UPLOADS_DEFAULT_REPO;
|
|
59
|
+
if (process.env.UPLOADS_DEFAULT_REF)
|
|
60
|
+
raw.UPLOADS_DEFAULT_REF = process.env.UPLOADS_DEFAULT_REF;
|
|
61
|
+
if (process.env.UPLOADS_DEFAULT_WIDTH)
|
|
62
|
+
raw.UPLOADS_DEFAULT_WIDTH = process.env.UPLOADS_DEFAULT_WIDTH;
|
|
63
|
+
if (process.env.UPLOADS_NO_GIT)
|
|
64
|
+
raw.UPLOADS_NO_GIT = process.env.UPLOADS_NO_GIT;
|
|
65
|
+
return parsePutDefaultsFromRaw(raw);
|
|
66
|
+
}
|
|
67
|
+
/** XDG default shared across buildinternet skills (github-screenshots, uploads, …). */
|
|
68
|
+
export function defaultConfigPath() {
|
|
69
|
+
const xdg = process.env.XDG_CONFIG_HOME ?? `${homedir()}/.config`;
|
|
70
|
+
return `${xdg}/buildinternet/config`;
|
|
71
|
+
}
|
|
72
|
+
/** Resolved config file path: explicit --env-file, then $BUILDINTERNET_CONFIG, then XDG default. */
|
|
73
|
+
export function resolveConfigPath(flags) {
|
|
74
|
+
if (flags?.envFile)
|
|
75
|
+
return flags.envFile;
|
|
76
|
+
if (process.env.BUILDINTERNET_CONFIG)
|
|
77
|
+
return process.env.BUILDINTERNET_CONFIG;
|
|
78
|
+
return defaultConfigPath();
|
|
79
|
+
}
|
|
80
|
+
function parseEnvLine(line) {
|
|
81
|
+
const trimmed = line.trim();
|
|
82
|
+
if (!trimmed || trimmed.startsWith("#"))
|
|
83
|
+
return undefined;
|
|
84
|
+
const exportPrefix = trimmed.startsWith("export ") ? trimmed.slice(7) : trimmed;
|
|
85
|
+
const eq = exportPrefix.indexOf("=");
|
|
86
|
+
if (eq === -1)
|
|
87
|
+
return undefined;
|
|
88
|
+
const key = exportPrefix.slice(0, eq).trim();
|
|
89
|
+
let value = exportPrefix.slice(eq + 1).trim();
|
|
90
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
91
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
92
|
+
value = value.slice(1, -1);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
const comment = value.search(/\s#/);
|
|
96
|
+
if (comment !== -1)
|
|
97
|
+
value = value.slice(0, comment).trimEnd();
|
|
98
|
+
}
|
|
99
|
+
return { key, value };
|
|
100
|
+
}
|
|
101
|
+
function isUploadsConfigKey(key) {
|
|
102
|
+
return UPLOADS_CONFIG_KEYS.includes(key);
|
|
103
|
+
}
|
|
104
|
+
/** Parse UPLOADS_* keys from a dotenv-style file. Missing file → empty object. */
|
|
105
|
+
export function loadConfigFile(path) {
|
|
106
|
+
if (!existsSync(path))
|
|
107
|
+
return {};
|
|
108
|
+
const out = {};
|
|
109
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
110
|
+
const parsed = parseEnvLine(line);
|
|
111
|
+
if (!parsed || !isUploadsConfigKey(parsed.key))
|
|
112
|
+
continue;
|
|
113
|
+
out[parsed.key] = parsed.value;
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
export function mergePutDefaults(...layers) {
|
|
118
|
+
const out = {};
|
|
119
|
+
for (const layer of layers) {
|
|
120
|
+
if (layer.prefix)
|
|
121
|
+
out.prefix = layer.prefix;
|
|
122
|
+
if (layer.repo)
|
|
123
|
+
out.repo = layer.repo;
|
|
124
|
+
if (layer.ref)
|
|
125
|
+
out.ref = layer.ref;
|
|
126
|
+
if (layer.width != null)
|
|
127
|
+
out.width = layer.width;
|
|
128
|
+
if (layer.noGit != null)
|
|
129
|
+
out.noGit = layer.noGit;
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
/** Put defaults from env, optional env-file, and user config (same precedence as client config). */
|
|
134
|
+
export function resolvePutDefaults(flags) {
|
|
135
|
+
const fromEnv = parsePutDefaultsFromEnv();
|
|
136
|
+
const fromEnvFile = flags?.envFile ? parsePutDefaultsFromRaw(loadConfigFile(flags.envFile)) : {};
|
|
137
|
+
const fromUser = flags?.envFile
|
|
138
|
+
? {}
|
|
139
|
+
: parsePutDefaultsFromRaw(loadConfigFile(resolveConfigPath(flags)));
|
|
140
|
+
return mergePutDefaults(fromUser, fromEnvFile, fromEnv);
|
|
141
|
+
}
|
|
142
|
+
export function redactToken(token) {
|
|
143
|
+
if (!token)
|
|
144
|
+
return "unset";
|
|
145
|
+
if (token.length <= 12)
|
|
146
|
+
return "set (redacted)";
|
|
147
|
+
return `set (${token.slice(0, 12)}…)`;
|
|
148
|
+
}
|
|
149
|
+
const INIT_HEADER = `# uploads.sh CLI — shared buildinternet config
|
|
150
|
+
# Other skills (e.g. github-screenshots) use this same file with their own prefixed keys.
|
|
151
|
+
#
|
|
152
|
+
# Resolution order (first match wins, per key):
|
|
153
|
+
# 1. CLI flags (--api-url, --token, --workspace)
|
|
154
|
+
# 2. environment variables
|
|
155
|
+
# 3. --env-file <path>
|
|
156
|
+
# 4. $BUILDINTERNET_CONFIG
|
|
157
|
+
# 5. ~/.config/buildinternet/config
|
|
158
|
+
#
|
|
159
|
+
# Mint a token: uploads setup
|
|
160
|
+
# Put defaults (optional): UPLOADS_DEFAULT_PREFIX, UPLOADS_DEFAULT_REPO, UPLOADS_DEFAULT_REF
|
|
161
|
+
`;
|
|
162
|
+
/** Create or update UPLOADS_* keys in the shared config file. Preserves other keys. */
|
|
163
|
+
export function writeConfigKeys(path, keys, opts) {
|
|
164
|
+
const entries = Object.entries(keys).filter(([, v]) => v !== undefined && v !== "");
|
|
165
|
+
if (entries.length === 0) {
|
|
166
|
+
throw new Error("no config values to write");
|
|
167
|
+
}
|
|
168
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
169
|
+
const existed = existsSync(path);
|
|
170
|
+
let lines = existed ? readFileSync(path, "utf8").split("\n") : [];
|
|
171
|
+
if (!existed) {
|
|
172
|
+
lines = INIT_HEADER.trimEnd().split("\n");
|
|
173
|
+
}
|
|
174
|
+
const updated = [];
|
|
175
|
+
for (const [key, value] of entries) {
|
|
176
|
+
const re = new RegExp(`^(?:export\\s+)?${key}=`);
|
|
177
|
+
const idx = lines.findIndex((line) => re.test(line.trim()));
|
|
178
|
+
const line = `${key}=${value}`;
|
|
179
|
+
if (idx === -1) {
|
|
180
|
+
if (lines.length > 0 && lines[lines.length - 1] !== "")
|
|
181
|
+
lines.push("");
|
|
182
|
+
lines.push(line);
|
|
183
|
+
updated.push(key);
|
|
184
|
+
}
|
|
185
|
+
else if (opts?.force || !parseEnvLine(lines[idx])?.value) {
|
|
186
|
+
lines[idx] = line;
|
|
187
|
+
updated.push(key);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
writeFileSync(path, lines.join("\n").replace(/\n*$/, "\n"), "utf8");
|
|
191
|
+
return { path, created: !existed, updated };
|
|
192
|
+
}
|
|
193
|
+
export function configValuesFromClient(config, defaults) {
|
|
194
|
+
const out = {};
|
|
195
|
+
if (config.apiUrl)
|
|
196
|
+
out.UPLOADS_API_URL = config.apiUrl;
|
|
197
|
+
if (config.workspace)
|
|
198
|
+
out.UPLOADS_WORKSPACE = config.workspace;
|
|
199
|
+
if (config.token)
|
|
200
|
+
out.UPLOADS_TOKEN = config.token;
|
|
201
|
+
Object.assign(out, putDefaultsToConfigValues(defaults ?? {}));
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
export { PUT_DEFAULT_KEY_MAP };
|