@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/config.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export { defaultConfigPath, resolveConfigPath, loadConfigFile, redactToken, writeConfigKeys, configValuesFromClient, putDefaultsToConfigValues, resolvePutDefaults, mergePutDefaults, UPLOADS_CONFIG_KEYS, type UploadsConfigKey, type UploadsConfigValues, type PutDefaults, } from "./config-file.js";
|
|
2
|
+
export interface UploadsClientConfig {
|
|
3
|
+
apiUrl: string;
|
|
4
|
+
workspace: string;
|
|
5
|
+
token: string;
|
|
6
|
+
}
|
|
7
|
+
export declare const DEFAULT_API_URL = "https://api.uploads.sh";
|
|
8
|
+
export declare const DEFAULT_WORKSPACE = "default";
|
|
9
|
+
/** Workspace encoded in minted tokens: `up_<workspace>_…` */
|
|
10
|
+
export declare function workspaceFromToken(token: string): string | undefined;
|
|
11
|
+
export declare function loadEnvFile(path: string): Partial<UploadsClientConfig>;
|
|
12
|
+
/** How the active workspace was chosen (for doctor hints). */
|
|
13
|
+
export type WorkspaceSource = "override" | "env" | "file" | "user-config" | "token" | "default";
|
|
14
|
+
export type ConfigValueSource = "flag" | "env" | "env-file" | "user-config" | "token" | "default";
|
|
15
|
+
export interface ResolvedConfig extends UploadsClientConfig {
|
|
16
|
+
workspaceSource: WorkspaceSource;
|
|
17
|
+
configPath: string;
|
|
18
|
+
configExists: boolean;
|
|
19
|
+
}
|
|
20
|
+
export interface ConfigSources {
|
|
21
|
+
apiUrl: ConfigValueSource;
|
|
22
|
+
workspace: WorkspaceSource;
|
|
23
|
+
token: ConfigValueSource;
|
|
24
|
+
}
|
|
25
|
+
export declare function describeConfigSources(flags?: Partial<UploadsClientConfig> & {
|
|
26
|
+
envFile?: string;
|
|
27
|
+
}): ConfigSources;
|
|
28
|
+
export declare function resolveApiUrl(flags?: {
|
|
29
|
+
apiUrl?: string;
|
|
30
|
+
envFile?: string;
|
|
31
|
+
}): string;
|
|
32
|
+
export declare function resolveConfig(flags?: Partial<UploadsClientConfig> & {
|
|
33
|
+
envFile?: string;
|
|
34
|
+
requireToken?: boolean;
|
|
35
|
+
}): ResolvedConfig;
|
|
36
|
+
/** Warn when an explicit workspace override may not match the token's embedded workspace. */
|
|
37
|
+
export declare function workspaceMismatch(config: ResolvedConfig): string | undefined;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { loadConfigFile, resolveConfigPath } from "./config-file.js";
|
|
3
|
+
import { UploadsError } from "./errors.js";
|
|
4
|
+
export { defaultConfigPath, resolveConfigPath, loadConfigFile, redactToken, writeConfigKeys, configValuesFromClient, putDefaultsToConfigValues, resolvePutDefaults, mergePutDefaults, UPLOADS_CONFIG_KEYS, } from "./config-file.js";
|
|
5
|
+
export const DEFAULT_API_URL = "https://api.uploads.sh";
|
|
6
|
+
export const DEFAULT_WORKSPACE = "default";
|
|
7
|
+
const TOKEN_WORKSPACE_RE = /^up_([a-z0-9][a-z0-9-]{1,62})_/;
|
|
8
|
+
/** Workspace encoded in minted tokens: `up_<workspace>_…` */
|
|
9
|
+
export function workspaceFromToken(token) {
|
|
10
|
+
return TOKEN_WORKSPACE_RE.exec(token)?.[1];
|
|
11
|
+
}
|
|
12
|
+
export function loadEnvFile(path) {
|
|
13
|
+
if (!existsSync(path)) {
|
|
14
|
+
throw new UploadsError(`--env-file not found: ${path}`, "USAGE");
|
|
15
|
+
}
|
|
16
|
+
const out = {};
|
|
17
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
18
|
+
const trimmed = line.trim();
|
|
19
|
+
if (!trimmed || trimmed.startsWith("#"))
|
|
20
|
+
continue;
|
|
21
|
+
const eq = trimmed.indexOf("=");
|
|
22
|
+
if (eq === -1)
|
|
23
|
+
continue;
|
|
24
|
+
const key = trimmed.slice(0, eq).trim();
|
|
25
|
+
let value = trimmed.slice(eq + 1).trim();
|
|
26
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
27
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
28
|
+
value = value.slice(1, -1);
|
|
29
|
+
}
|
|
30
|
+
if (key === "UPLOADS_API_URL")
|
|
31
|
+
out.apiUrl = value;
|
|
32
|
+
else if (key === "UPLOADS_WORKSPACE")
|
|
33
|
+
out.workspace = value;
|
|
34
|
+
else if (key === "UPLOADS_TOKEN")
|
|
35
|
+
out.token = value;
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
function layerFromUserConfig(flags) {
|
|
40
|
+
if (flags?.envFile)
|
|
41
|
+
return {};
|
|
42
|
+
const path = resolveConfigPath(flags);
|
|
43
|
+
const raw = loadConfigFile(path);
|
|
44
|
+
return {
|
|
45
|
+
apiUrl: raw.UPLOADS_API_URL,
|
|
46
|
+
workspace: raw.UPLOADS_WORKSPACE,
|
|
47
|
+
token: raw.UPLOADS_TOKEN,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function pickApiUrl(flags) {
|
|
51
|
+
const fromEnvFile = flags?.envFile ? loadEnvFile(flags.envFile) : {};
|
|
52
|
+
const fromUser = layerFromUserConfig(flags);
|
|
53
|
+
return (flags?.apiUrl ??
|
|
54
|
+
process.env.UPLOADS_API_URL ??
|
|
55
|
+
fromEnvFile.apiUrl ??
|
|
56
|
+
fromUser.apiUrl ??
|
|
57
|
+
DEFAULT_API_URL);
|
|
58
|
+
}
|
|
59
|
+
export function describeConfigSources(flags) {
|
|
60
|
+
const fromEnvFile = flags?.envFile ? loadEnvFile(flags.envFile) : {};
|
|
61
|
+
const fromUser = layerFromUserConfig(flags);
|
|
62
|
+
const token = flags?.token ?? process.env.UPLOADS_TOKEN ?? fromEnvFile.token ?? fromUser.token;
|
|
63
|
+
let apiUrl = "default";
|
|
64
|
+
if (flags?.apiUrl)
|
|
65
|
+
apiUrl = "flag";
|
|
66
|
+
else if (process.env.UPLOADS_API_URL)
|
|
67
|
+
apiUrl = "env";
|
|
68
|
+
else if (fromEnvFile.apiUrl)
|
|
69
|
+
apiUrl = "env-file";
|
|
70
|
+
else if (fromUser.apiUrl)
|
|
71
|
+
apiUrl = "user-config";
|
|
72
|
+
let workspaceSource = "default";
|
|
73
|
+
if (flags?.workspace)
|
|
74
|
+
workspaceSource = "override";
|
|
75
|
+
else if (process.env.UPLOADS_WORKSPACE)
|
|
76
|
+
workspaceSource = "env";
|
|
77
|
+
else if (fromEnvFile.workspace)
|
|
78
|
+
workspaceSource = "file";
|
|
79
|
+
else if (fromUser.workspace)
|
|
80
|
+
workspaceSource = "user-config";
|
|
81
|
+
else if (token && workspaceFromToken(token))
|
|
82
|
+
workspaceSource = "token";
|
|
83
|
+
let tokenSource = "default";
|
|
84
|
+
if (flags?.token)
|
|
85
|
+
tokenSource = "flag";
|
|
86
|
+
else if (process.env.UPLOADS_TOKEN)
|
|
87
|
+
tokenSource = "env";
|
|
88
|
+
else if (fromEnvFile.token)
|
|
89
|
+
tokenSource = "env-file";
|
|
90
|
+
else if (fromUser.token)
|
|
91
|
+
tokenSource = "user-config";
|
|
92
|
+
return { apiUrl, workspace: workspaceSource, token: tokenSource };
|
|
93
|
+
}
|
|
94
|
+
export function resolveApiUrl(flags) {
|
|
95
|
+
return pickApiUrl(flags);
|
|
96
|
+
}
|
|
97
|
+
export function resolveConfig(flags) {
|
|
98
|
+
const fromEnvFile = flags?.envFile ? loadEnvFile(flags.envFile) : {};
|
|
99
|
+
const fromUser = layerFromUserConfig(flags);
|
|
100
|
+
const configPath = resolveConfigPath(flags);
|
|
101
|
+
const token = flags?.token ?? process.env.UPLOADS_TOKEN ?? fromEnvFile.token ?? fromUser.token;
|
|
102
|
+
const apiUrl = pickApiUrl(flags);
|
|
103
|
+
let workspace;
|
|
104
|
+
let workspaceSource;
|
|
105
|
+
if (flags?.workspace) {
|
|
106
|
+
workspace = flags.workspace;
|
|
107
|
+
workspaceSource = "override";
|
|
108
|
+
}
|
|
109
|
+
else if (process.env.UPLOADS_WORKSPACE) {
|
|
110
|
+
workspace = process.env.UPLOADS_WORKSPACE;
|
|
111
|
+
workspaceSource = "env";
|
|
112
|
+
}
|
|
113
|
+
else if (fromEnvFile.workspace) {
|
|
114
|
+
workspace = fromEnvFile.workspace;
|
|
115
|
+
workspaceSource = "file";
|
|
116
|
+
}
|
|
117
|
+
else if (fromUser.workspace) {
|
|
118
|
+
workspace = fromUser.workspace;
|
|
119
|
+
workspaceSource = "user-config";
|
|
120
|
+
}
|
|
121
|
+
else if (token && workspaceFromToken(token)) {
|
|
122
|
+
workspace = workspaceFromToken(token);
|
|
123
|
+
workspaceSource = "token";
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
workspace = DEFAULT_WORKSPACE;
|
|
127
|
+
workspaceSource = "default";
|
|
128
|
+
}
|
|
129
|
+
if (flags?.requireToken !== false && !token) {
|
|
130
|
+
throw new UploadsError(missingTokenMessage(configPath), "MISSING_TOKEN");
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
apiUrl,
|
|
134
|
+
workspace,
|
|
135
|
+
token: token ?? "",
|
|
136
|
+
workspaceSource,
|
|
137
|
+
configPath,
|
|
138
|
+
configExists: existsSync(configPath),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
function missingTokenMessage(configPath) {
|
|
142
|
+
return [
|
|
143
|
+
"UPLOADS_TOKEN is required.",
|
|
144
|
+
` uploads setup --token <token> # guided setup → ${configPath}`,
|
|
145
|
+
` uploads config init --token <token> # writes ${configPath}`,
|
|
146
|
+
" or set UPLOADS_TOKEN in env, pass --token, or use --env-file",
|
|
147
|
+
].join("\n");
|
|
148
|
+
}
|
|
149
|
+
/** Warn when an explicit workspace override may not match the token's embedded workspace. */
|
|
150
|
+
export function workspaceMismatch(config) {
|
|
151
|
+
const fromToken = workspaceFromToken(config.token);
|
|
152
|
+
if (!fromToken || fromToken === config.workspace)
|
|
153
|
+
return undefined;
|
|
154
|
+
if (config.workspaceSource === "token" || config.workspaceSource === "default")
|
|
155
|
+
return undefined;
|
|
156
|
+
return `workspace override "${config.workspace}" (token encodes "${fromToken}") — ensure the token is valid for the override workspace`;
|
|
157
|
+
}
|
package/dist/embed.d.ts
ADDED
package/dist/embed.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** GitHub-embed helpers (content type + markdown). */
|
|
2
|
+
export function inferContentType(filename) {
|
|
3
|
+
const ext = filename.includes(".")
|
|
4
|
+
? filename.slice(filename.lastIndexOf(".") + 1).toLowerCase()
|
|
5
|
+
: "";
|
|
6
|
+
switch (ext) {
|
|
7
|
+
case "png":
|
|
8
|
+
return "image/png";
|
|
9
|
+
case "jpg":
|
|
10
|
+
case "jpeg":
|
|
11
|
+
return "image/jpeg";
|
|
12
|
+
case "gif":
|
|
13
|
+
return "image/gif";
|
|
14
|
+
case "webp":
|
|
15
|
+
return "image/webp";
|
|
16
|
+
case "svg":
|
|
17
|
+
return "image/svg+xml";
|
|
18
|
+
case "mp4":
|
|
19
|
+
return "video/mp4";
|
|
20
|
+
default:
|
|
21
|
+
return "application/octet-stream";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function buildMarkdown(url, opts) {
|
|
25
|
+
if (opts.width) {
|
|
26
|
+
const alt = opts.alt.replace(/"/g, """);
|
|
27
|
+
return `<img width="${opts.width}" alt="${alt}" src="${url}">`;
|
|
28
|
+
}
|
|
29
|
+
return ``;
|
|
30
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export type UploadsErrorCode = "MISSING_TOKEN" | "NO_PUBLIC_URL" | "NOT_FOUND" | "UNAUTHORIZED" | "INVALID_KEY" | "API_ERROR" | "NETWORK" | "USAGE";
|
|
2
|
+
export declare class UploadsError extends Error {
|
|
3
|
+
readonly code: UploadsErrorCode;
|
|
4
|
+
readonly status?: number;
|
|
5
|
+
constructor(message: string, code: UploadsErrorCode, status?: number);
|
|
6
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type GhTarget } from "./github.js";
|
|
2
|
+
/** Runs a command and returns stdout; throws on non-zero exit. Injectable for tests. */
|
|
3
|
+
export type CommandRunner = (cmd: string, args: string[], input?: string) => string;
|
|
4
|
+
export declare const execRunner: CommandRunner;
|
|
5
|
+
/**
|
|
6
|
+
* Resolve "owner/name". Order: explicit --repo (validated) → `gh repo view`
|
|
7
|
+
* (fork-aware) → parse the origin remote → UsageError.
|
|
8
|
+
*/
|
|
9
|
+
export declare function resolveRepo(explicit: string | undefined, run?: CommandRunner): string;
|
|
10
|
+
/** Resolve the pull request associated with the current branch. */
|
|
11
|
+
export declare function resolveCurrentPullRequest(repo: string, run?: CommandRunner): GhTarget;
|
|
12
|
+
/**
|
|
13
|
+
* Create the managed attachments comment, or edit it in place if it already
|
|
14
|
+
* exists. Never touches any other comment. Body is passed via stdin
|
|
15
|
+
* (`-F body=@-`) so it is never shell-interpolated.
|
|
16
|
+
*/
|
|
17
|
+
export declare function upsertAttachmentsComment(target: GhTarget, body: string, run?: CommandRunner): {
|
|
18
|
+
created: boolean;
|
|
19
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { UsageError } from "./cli-args.js";
|
|
3
|
+
import { ATTACHMENTS_MARKER, isValidRepo, parseRepoFromRemoteUrl, } from "./github.js";
|
|
4
|
+
export const execRunner = (cmd, args, input) => execFileSync(cmd, args, { encoding: "utf8", input, stdio: ["pipe", "pipe", "pipe"] });
|
|
5
|
+
/**
|
|
6
|
+
* Resolve "owner/name". Order: explicit --repo (validated) → `gh repo view`
|
|
7
|
+
* (fork-aware) → parse the origin remote → UsageError.
|
|
8
|
+
*/
|
|
9
|
+
export function resolveRepo(explicit, run = execRunner) {
|
|
10
|
+
if (explicit !== undefined) {
|
|
11
|
+
if (!isValidRepo(explicit)) {
|
|
12
|
+
throw new UsageError(`--repo must be owner/name (got: ${explicit})`);
|
|
13
|
+
}
|
|
14
|
+
return explicit;
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
const out = run("gh", [
|
|
18
|
+
"repo",
|
|
19
|
+
"view",
|
|
20
|
+
"--json",
|
|
21
|
+
"nameWithOwner",
|
|
22
|
+
"--jq",
|
|
23
|
+
".nameWithOwner",
|
|
24
|
+
]).trim();
|
|
25
|
+
if (isValidRepo(out))
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// gh missing, unauthenticated, or not in a repo — fall through
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
const url = run("git", ["config", "--get", "remote.origin.url"]).trim();
|
|
33
|
+
const parsed = parseRepoFromRemoteUrl(url);
|
|
34
|
+
if (parsed)
|
|
35
|
+
return parsed;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// not a git repo — fall through
|
|
39
|
+
}
|
|
40
|
+
throw new UsageError("could not infer repository from git — pass --repo owner/name");
|
|
41
|
+
}
|
|
42
|
+
/** Resolve the pull request associated with the current branch. */
|
|
43
|
+
export function resolveCurrentPullRequest(repo, run = execRunner) {
|
|
44
|
+
try {
|
|
45
|
+
const out = run("gh", [
|
|
46
|
+
"pr",
|
|
47
|
+
"view",
|
|
48
|
+
"--repo",
|
|
49
|
+
repo,
|
|
50
|
+
"--json",
|
|
51
|
+
"number",
|
|
52
|
+
"--jq",
|
|
53
|
+
".number",
|
|
54
|
+
]).trim();
|
|
55
|
+
if (/^\d+$/.test(out) && Number(out) > 0) {
|
|
56
|
+
return { repo, kind: "pull", num: Number.parseInt(out, 10) };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// Normalize gh's varying errors into a stable, actionable CLI message.
|
|
61
|
+
}
|
|
62
|
+
throw new UsageError("could not infer a pull request for the current branch — pass --pr <num> or --issue <num>");
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* PR comments live on the issues endpoint, so one path covers PRs and issues.
|
|
66
|
+
* Only the first 100 comments are searched (accepted v1 limitation).
|
|
67
|
+
*/
|
|
68
|
+
function findManagedComment(target, run) {
|
|
69
|
+
const raw = run("gh", ["api", `repos/${target.repo}/issues/${target.num}/comments?per_page=100`]);
|
|
70
|
+
const comments = JSON.parse(raw);
|
|
71
|
+
return comments.find((c) => typeof c.body === "string" && c.body.includes(ATTACHMENTS_MARKER));
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Create the managed attachments comment, or edit it in place if it already
|
|
75
|
+
* exists. Never touches any other comment. Body is passed via stdin
|
|
76
|
+
* (`-F body=@-`) so it is never shell-interpolated.
|
|
77
|
+
*/
|
|
78
|
+
export function upsertAttachmentsComment(target, body, run = execRunner) {
|
|
79
|
+
const existing = findManagedComment(target, run);
|
|
80
|
+
if (existing) {
|
|
81
|
+
run("gh", [
|
|
82
|
+
"api",
|
|
83
|
+
`repos/${target.repo}/issues/comments/${existing.id}`,
|
|
84
|
+
"-X",
|
|
85
|
+
"PATCH",
|
|
86
|
+
"-F",
|
|
87
|
+
"body=@-",
|
|
88
|
+
], body);
|
|
89
|
+
return { created: false };
|
|
90
|
+
}
|
|
91
|
+
run("gh", ["api", `repos/${target.repo}/issues/${target.num}/comments`, "-F", "body=@-"], body);
|
|
92
|
+
return { created: true };
|
|
93
|
+
}
|
package/dist/github.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export type GhTargetKind = "pull" | "issues";
|
|
2
|
+
export interface GhTarget {
|
|
3
|
+
/** "owner/name" */
|
|
4
|
+
repo: string;
|
|
5
|
+
kind: GhTargetKind;
|
|
6
|
+
num: number;
|
|
7
|
+
}
|
|
8
|
+
export declare function isValidRepo(repo: string): boolean;
|
|
9
|
+
/** Parse "owner/name" from a git remote URL (SSH or HTTPS), else undefined. */
|
|
10
|
+
export declare function parseRepoFromRemoteUrl(url: string): string | undefined;
|
|
11
|
+
export declare function ghKeyPrefix(target: GhTarget): string;
|
|
12
|
+
/**
|
|
13
|
+
* Stable attachment key: same filename → same key → same public URL, so
|
|
14
|
+
* re-uploading updates every existing embed. Deliberately NO content hash
|
|
15
|
+
* (unlike buildScreenshotKey).
|
|
16
|
+
*/
|
|
17
|
+
export declare function ghAttachmentKey(target: GhTarget, filename: string): string;
|
|
18
|
+
/** Hidden marker identifying the one comment this CLI manages. Never change it — existing comments are found by exact match. */
|
|
19
|
+
export declare const ATTACHMENTS_MARKER = "<!-- uploads.sh:attachments -->";
|
|
20
|
+
export interface AttachmentItem {
|
|
21
|
+
key: string;
|
|
22
|
+
url: string | null;
|
|
23
|
+
}
|
|
24
|
+
export declare function attachmentsCommentBody(items: AttachmentItem[]): string;
|
package/dist/github.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { inferContentType } from "./embed.js";
|
|
2
|
+
import { sanitizeKeySegment } from "./keys.js";
|
|
3
|
+
const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
4
|
+
export function isValidRepo(repo) {
|
|
5
|
+
return REPO_RE.test(repo);
|
|
6
|
+
}
|
|
7
|
+
/** Parse "owner/name" from a git remote URL (SSH or HTTPS), else undefined. */
|
|
8
|
+
export function parseRepoFromRemoteUrl(url) {
|
|
9
|
+
const match = url.trim().match(/[/:]([^/:\s]+\/[^/:\s]+?)(?:\.git)?\/?$/);
|
|
10
|
+
const repo = match?.[1];
|
|
11
|
+
return repo && isValidRepo(repo) ? repo : undefined;
|
|
12
|
+
}
|
|
13
|
+
export function ghKeyPrefix(target) {
|
|
14
|
+
const [owner, name] = target.repo.split("/");
|
|
15
|
+
return `gh/${sanitizeKeySegment(owner)}/${sanitizeKeySegment(name)}/${target.kind}/${target.num}/`;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Stable attachment key: same filename → same key → same public URL, so
|
|
19
|
+
* re-uploading updates every existing embed. Deliberately NO content hash
|
|
20
|
+
* (unlike buildScreenshotKey).
|
|
21
|
+
*/
|
|
22
|
+
export function ghAttachmentKey(target, filename) {
|
|
23
|
+
return `${ghKeyPrefix(target)}${sanitizeKeySegment(filename)}`;
|
|
24
|
+
}
|
|
25
|
+
/** Hidden marker identifying the one comment this CLI manages. Never change it — existing comments are found by exact match. */
|
|
26
|
+
export const ATTACHMENTS_MARKER = "<!-- uploads.sh:attachments -->";
|
|
27
|
+
export function attachmentsCommentBody(items) {
|
|
28
|
+
const sorted = items.toSorted((a, b) => a.key.localeCompare(b.key));
|
|
29
|
+
const lines = [ATTACHMENTS_MARKER, "### 📎 Attachments", ""];
|
|
30
|
+
for (const item of sorted) {
|
|
31
|
+
const name = item.key.slice(item.key.lastIndexOf("/") + 1);
|
|
32
|
+
if (item.url && inferContentType(name).startsWith("image/")) {
|
|
33
|
+
lines.push(``);
|
|
34
|
+
}
|
|
35
|
+
else if (item.url) {
|
|
36
|
+
lines.push(`- [${name}](${item.url})`);
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
lines.push(`- ${name}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
lines.push("", "<sub>Maintained by uploads.sh — re-uploading a file with the same name updates it everywhere it is embedded.</sub>");
|
|
43
|
+
return lines.join("\n");
|
|
44
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { inferContentType, buildMarkdown } from "./embed.js";
|
|
2
|
+
export { sanitizeKeySegment, sha256Short, deriveRepoFromGit, buildScreenshotKey } from "./keys.js";
|
|
3
|
+
export { DEFAULT_API_URL, DEFAULT_WORKSPACE, UPLOADS_CONFIG_KEYS, defaultConfigPath, resolveConfigPath, loadConfigFile, loadEnvFile, resolveApiUrl, resolveConfig, describeConfigSources, redactToken, writeConfigKeys, configValuesFromClient, putDefaultsToConfigValues, resolvePutDefaults, mergePutDefaults, workspaceFromToken, workspaceMismatch, type UploadsClientConfig, type ResolvedConfig, type WorkspaceSource, type ConfigValueSource, type ConfigSources, type UploadsConfigKey, type UploadsConfigValues, type PutDefaults, } from "./config.js";
|
|
4
|
+
export { UploadsError, type UploadsErrorCode } from "./errors.js";
|
|
5
|
+
export { createUploadsClient, type UploadsClient, type PutOptions, type ListOptions, type PutResult, type ListItem, type ListResult, type HeadResult, type DeleteResult, type HealthResult, } from "./client.js";
|
|
6
|
+
export { ATTACHMENTS_MARKER, attachmentsCommentBody, ghAttachmentKey, ghKeyPrefix, isValidRepo, parseRepoFromRemoteUrl, type AttachmentItem, type GhTarget, type GhTargetKind, } from "./github.js";
|
|
7
|
+
export { execRunner, resolveRepo, upsertAttachmentsComment, type CommandRunner, } from "./github-gh.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { inferContentType, buildMarkdown } from "./embed.js";
|
|
2
|
+
export { sanitizeKeySegment, sha256Short, deriveRepoFromGit, buildScreenshotKey } from "./keys.js";
|
|
3
|
+
export { DEFAULT_API_URL, DEFAULT_WORKSPACE, UPLOADS_CONFIG_KEYS, defaultConfigPath, resolveConfigPath, loadConfigFile, loadEnvFile, resolveApiUrl, resolveConfig, describeConfigSources, redactToken, writeConfigKeys, configValuesFromClient, putDefaultsToConfigValues, resolvePutDefaults, mergePutDefaults, workspaceFromToken, workspaceMismatch, } from "./config.js";
|
|
4
|
+
export { UploadsError } from "./errors.js";
|
|
5
|
+
export { createUploadsClient, } from "./client.js";
|
|
6
|
+
export { ATTACHMENTS_MARKER, attachmentsCommentBody, ghAttachmentKey, ghKeyPrefix, isValidRepo, parseRepoFromRemoteUrl, } from "./github.js";
|
|
7
|
+
export { execRunner, resolveRepo, upsertAttachmentsComment, } from "./github-gh.js";
|
package/dist/keys.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare function sanitizeKeySegment(s: string): string;
|
|
2
|
+
export declare function sha256Short(bytes: Uint8Array): Promise<string>;
|
|
3
|
+
export declare function deriveRepoFromGit(): string | undefined;
|
|
4
|
+
export declare function buildScreenshotKey(opts: {
|
|
5
|
+
filename: string;
|
|
6
|
+
fileBytes: Uint8Array;
|
|
7
|
+
prefix?: string;
|
|
8
|
+
repo?: string;
|
|
9
|
+
ref?: string;
|
|
10
|
+
deriveRepoFromGit?: boolean;
|
|
11
|
+
}): Promise<string>;
|
package/dist/keys.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
export function sanitizeKeySegment(s) {
|
|
3
|
+
return s.replace(/[^A-Za-z0-9._-]/g, "-");
|
|
4
|
+
}
|
|
5
|
+
export async function sha256Short(bytes) {
|
|
6
|
+
const hash = await crypto.subtle.digest("SHA-256", new Uint8Array(bytes));
|
|
7
|
+
return Array.from(new Uint8Array(hash))
|
|
8
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
9
|
+
.join("")
|
|
10
|
+
.slice(0, 6);
|
|
11
|
+
}
|
|
12
|
+
export function deriveRepoFromGit() {
|
|
13
|
+
try {
|
|
14
|
+
const url = execSync("git config --get remote.origin.url", { encoding: "utf8" }).trim();
|
|
15
|
+
const match = url.match(/[/:]([^/]+?)(?:\.git)?$/);
|
|
16
|
+
return match?.[1];
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export async function buildScreenshotKey(opts) {
|
|
23
|
+
const dot = opts.filename.lastIndexOf(".");
|
|
24
|
+
const ext = dot >= 0 ? opts.filename.slice(dot + 1) : "";
|
|
25
|
+
const stem = dot >= 0 ? opts.filename.slice(0, dot) : opts.filename;
|
|
26
|
+
let repo = opts.repo;
|
|
27
|
+
if (!repo && opts.deriveRepoFromGit)
|
|
28
|
+
repo = deriveRepoFromGit();
|
|
29
|
+
repo = sanitizeKeySegment(repo ?? "misc");
|
|
30
|
+
const ref = sanitizeKeySegment(opts.ref ?? new Date().toISOString().slice(0, 10));
|
|
31
|
+
const short = await sha256Short(opts.fileBytes);
|
|
32
|
+
const prefix = sanitizeKeySegment(opts.prefix ?? "screenshots");
|
|
33
|
+
return `${prefix}/${repo}/${ref}/${sanitizeKeySegment(stem)}-${short}${ext ? `.${sanitizeKeySegment(ext)}` : ""}`;
|
|
34
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@buildinternet/uploads",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI and client for uploads.sh — workspace-scoped image hosting for GitHub embeds",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/buildinternet/uploads.git",
|
|
10
|
+
"directory": "packages/uploads"
|
|
11
|
+
},
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"import": "./dist/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./agent": {
|
|
19
|
+
"types": "./dist/agent.d.ts",
|
|
20
|
+
"import": "./dist/agent.js"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"bin": {
|
|
24
|
+
"uploads": "./bin/uploads.js"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"bin",
|
|
28
|
+
"dist",
|
|
29
|
+
"README.md"
|
|
30
|
+
],
|
|
31
|
+
"scripts": {
|
|
32
|
+
"test": "vitest run",
|
|
33
|
+
"typecheck": "tsc --noEmit",
|
|
34
|
+
"build": "tsc",
|
|
35
|
+
"pack:check": "node ./scripts/check-pack.mjs",
|
|
36
|
+
"prepublishOnly": "npm run build"
|
|
37
|
+
},
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=22"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"files-sdk": "^2.1.0"
|
|
43
|
+
},
|
|
44
|
+
"peerDependenciesMeta": {
|
|
45
|
+
"files-sdk": {
|
|
46
|
+
"optional": true
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/node": "^26.1.0",
|
|
51
|
+
"ai": "^6.0.0",
|
|
52
|
+
"files-sdk": "^2.1.0",
|
|
53
|
+
"typescript": "^6.0.3",
|
|
54
|
+
"vitest": "^4.1.10"
|
|
55
|
+
},
|
|
56
|
+
"publishConfig": {
|
|
57
|
+
"access": "public",
|
|
58
|
+
"provenance": true
|
|
59
|
+
}
|
|
60
|
+
}
|