Skip to content

Commit bcb70f0

Browse files
AddonoCopilot
andcommitted
feat: implement CLI upload command with multi-strategy support
- Implement uploadCommand that uses all four upload strategies - Refactor CLI index to use dynamic command imports - Create command modules: upload, login (stub), config (stub), mcp (stub) - Support strategy selection via CLI flag - Support output formats: markdown (default), url, json - Proper error handling and exit codes - Integrate with core library for file validation and target parsing The upload command now: 1. Parses target references (URLs, shorthand, local) 2. Validates image files (format, size, existence) 3. Tries strategies in order of availability (browser-session, cookie-extraction, release-asset, repo-branch) 4. Outputs results in requested format (markdown for GitHub comments) CLI usage: gh-attach upload ./image.png --target owner/repo#42 gh-attach upload ./image.png --target #42 --strategy release-asset gh-attach upload ./image.png --target #42 --format json Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 7c2dd54 commit bcb70f0

5 files changed

Lines changed: 211 additions & 17 deletions

File tree

src/cli/commands/config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/**
2+
* Config command implementation.
3+
*/
4+
export async function configCommand(action: string, key?: string, value?: string) {
5+
console.log(`Config command: ${action} ${key || ""} ${value || ""}`);
6+
// TODO: Implement config command
7+
}

src/cli/commands/login.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/**
2+
* Login command implementation.
3+
*/
4+
export async function loginCommand(options: any) {
5+
if (options.status) {
6+
console.log("Not implemented: login status check");
7+
} else {
8+
console.log("Not implemented: interactive login");
9+
}
10+
// TODO: Implement login command
11+
}

src/cli/commands/mcp.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/**
2+
* MCP server command implementation.
3+
*/
4+
export async function mcpCommand(options: any) {
5+
console.log(`MCP server starting with transport: ${options.transport}`);
6+
// TODO: Implement MCP server command
7+
}

src/cli/commands/upload.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { createReleaseAssetStrategy } from "../../core/strategies/releaseAsset.js";
2+
import { createBrowserSessionStrategy } from "../../core/strategies/browserSession.js";
3+
import { createCookieExtractionStrategy } from "../../core/strategies/cookieExtraction.js";
4+
import { createRepoBranchStrategy } from "../../core/strategies/repoBranch.js";
5+
import { parseTarget } from "../../core/target.js";
6+
import { validateFile } from "../../core/validation.js";
7+
import { upload } from "../../core/upload.js";
8+
import type { UploadStrategy } from "../../core/types.js";
9+
10+
interface UploadOptions {
11+
target: string;
12+
strategy?: string;
13+
format?: "markdown" | "url" | "json";
14+
stdin?: boolean;
15+
filename?: string;
16+
}
17+
18+
/**
19+
* Upload command implementation.
20+
*/
21+
export async function uploadCommand(files: string[], options: UploadOptions) {
22+
// Parse target
23+
let uploadTarget;
24+
try {
25+
uploadTarget = parseTarget(options.target);
26+
} catch (err) {
27+
if (err instanceof Error) {
28+
throw new Error(`Invalid target: ${err.message}`);
29+
}
30+
throw err;
31+
}
32+
33+
// Build strategies list
34+
const strategies: UploadStrategy[] = [];
35+
36+
// If a specific strategy is requested, only use that one
37+
if (options.strategy) {
38+
const token = process.env.GITHUB_TOKEN;
39+
const cookies = process.env.GH_ATTACH_COOKIES;
40+
41+
switch (options.strategy) {
42+
case "release-asset":
43+
if (!token) {
44+
throw new Error(
45+
"release-asset strategy requires GITHUB_TOKEN environment variable",
46+
);
47+
}
48+
strategies.push(createReleaseAssetStrategy(token));
49+
break;
50+
case "browser-session":
51+
if (!cookies) {
52+
throw new Error(
53+
"browser-session strategy requires GH_ATTACH_COOKIES environment variable",
54+
);
55+
}
56+
strategies.push(createBrowserSessionStrategy(cookies));
57+
break;
58+
case "cookie-extraction":
59+
strategies.push(createCookieExtractionStrategy());
60+
break;
61+
case "repo-branch":
62+
if (!token) {
63+
throw new Error(
64+
"repo-branch strategy requires GITHUB_TOKEN environment variable",
65+
);
66+
}
67+
strategies.push(createRepoBranchStrategy(token));
68+
break;
69+
default:
70+
throw new Error(`Unknown strategy: ${options.strategy}`);
71+
}
72+
} else {
73+
// Default strategy order: browser-session, cookie-extraction, release-asset, repo-branch
74+
const token = process.env.GITHUB_TOKEN;
75+
const cookies = process.env.GH_ATTACH_COOKIES;
76+
77+
if (cookies) {
78+
strategies.push(createBrowserSessionStrategy(cookies));
79+
}
80+
strategies.push(createCookieExtractionStrategy());
81+
if (token) {
82+
strategies.push(createReleaseAssetStrategy(token));
83+
strategies.push(createRepoBranchStrategy(token));
84+
}
85+
}
86+
87+
if (strategies.length === 0) {
88+
throw new Error(
89+
"No authentication available. Set GITHUB_TOKEN or GH_ATTACH_COOKIES",
90+
);
91+
}
92+
93+
// Process files
94+
const results = [];
95+
for (const file of files) {
96+
// Validate file
97+
try {
98+
await validateFile(file);
99+
} catch (err) {
100+
if (err instanceof Error) {
101+
throw new Error(`File validation failed: ${err.message}`);
102+
}
103+
throw err;
104+
}
105+
106+
// Upload file
107+
try {
108+
const result = await upload(file, uploadTarget, strategies);
109+
results.push(result);
110+
} catch (err) {
111+
if (err instanceof Error) {
112+
throw new Error(`Upload failed: ${err.message}`);
113+
}
114+
throw err;
115+
}
116+
}
117+
118+
// Output results
119+
const format = options.format || "markdown";
120+
for (const result of results) {
121+
switch (format) {
122+
case "url":
123+
console.log(result.url);
124+
break;
125+
case "json":
126+
console.log(JSON.stringify(result, null, 2));
127+
break;
128+
case "markdown":
129+
default:
130+
console.log(result.markdown);
131+
}
132+
}
133+
}

src/cli/index.ts

Lines changed: 53 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,17 @@
44
* gh-attach CLI entry point.
55
*/
66

7+
import { readFileSync } from "fs";
78
import { Command } from "commander";
89

10+
const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
11+
912
const program = new Command();
1013

1114
program
1215
.name("gh-attach")
1316
.description("Upload images to GitHub issues, PRs, and comments")
14-
.version("0.0.0-development");
17+
.version(pkg.version);
1518

1619
program
1720
.command("upload")
@@ -22,21 +25,37 @@ program
2225
.option("--format <type>", "Output format: markdown, url, json", "markdown")
2326
.option("--stdin", "Read image from stdin")
2427
.option("--filename <name>", "Filename when using --stdin")
25-
.action(async (_files, _options) => {
26-
// TODO: Implement upload command
27-
console.error("Upload command not yet implemented");
28-
process.exit(1);
28+
.action(async (files, options) => {
29+
try {
30+
const { uploadCommand } = await import("./commands/upload.js");
31+
await uploadCommand(files, options);
32+
} catch (err) {
33+
if (err instanceof Error) {
34+
console.error(`Error: ${err.message}`);
35+
} else {
36+
console.error(`Error: ${String(err)}`);
37+
}
38+
process.exit(1);
39+
}
2940
});
3041

3142
program
3243
.command("login")
3344
.description("Authenticate with GitHub via browser")
3445
.option("--state-path <path>", "Path to save session state")
3546
.option("--status", "Check current authentication status")
36-
.action(async (_options) => {
37-
// TODO: Implement login command
38-
console.error("Login command not yet implemented");
39-
process.exit(1);
47+
.action(async (options) => {
48+
try {
49+
const { loginCommand } = await import("./commands/login.js");
50+
await loginCommand(options);
51+
} catch (err) {
52+
if (err instanceof Error) {
53+
console.error(`Error: ${err.message}`);
54+
} else {
55+
console.error(`Error: ${String(err)}`);
56+
}
57+
process.exit(1);
58+
}
4059
});
4160

4261
program
@@ -45,21 +64,38 @@ program
4564
.argument("<action>", "Action: list, set, get")
4665
.argument("[key]", "Configuration key")
4766
.argument("[value]", "Configuration value")
48-
.action(async (_action, _key, _value) => {
49-
// TODO: Implement config command
50-
console.error("Config command not yet implemented");
51-
process.exit(1);
67+
.action(async (action, key, value) => {
68+
try {
69+
const { configCommand } = await import("./commands/config.js");
70+
await configCommand(action, key, value);
71+
} catch (err) {
72+
if (err instanceof Error) {
73+
console.error(`Error: ${err.message}`);
74+
} else {
75+
console.error(`Error: ${String(err)}`);
76+
}
77+
process.exit(1);
78+
}
5279
});
5380

5481
program
5582
.command("mcp")
5683
.description("Start the MCP server")
5784
.option("--transport <type>", "Transport: stdio, http", "stdio")
5885
.option("--port <number>", "Port for HTTP transport", "3000")
59-
.action(async (_options) => {
60-
// TODO: Implement MCP server command
61-
console.error("MCP server not yet implemented");
62-
process.exit(1);
86+
.action(async (options) => {
87+
try {
88+
const { mcpCommand } = await import("./commands/mcp.js");
89+
await mcpCommand(options);
90+
} catch (err) {
91+
if (err instanceof Error) {
92+
console.error(`Error: ${err.message}`);
93+
} else {
94+
console.error(`Error: ${String(err)}`);
95+
}
96+
process.exit(1);
97+
}
6398
});
6499

65100
program.parse();
101+

0 commit comments

Comments
 (0)