ghimg 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 ADDED
@@ -0,0 +1,60 @@
1
+ # ghimg
2
+
3
+ Upload images to GitHub's `user-attachments` CDN from the command line and get a
4
+ markdown reference back. Replicates GitHub's web UI upload flow, so the resulting
5
+ URLs are scoped to the repository they're uploaded to (private-repo images stay
6
+ private).
7
+
8
+ ## Setup
9
+
10
+ ```bash
11
+ npx ghimg init
12
+ ```
13
+
14
+ The wizard asks for your github.com `user_session` cookie value — copy it from
15
+ devtools (`Application -> Cookies -> https://github.com -> user_session`). It's
16
+ saved to `.env` as `GH_SESSION_TOKEN` and validated against GitHub.
17
+
18
+ ## Usage
19
+
20
+ ```bash
21
+ # repo inferred from the current git remote
22
+ npx ghimg upload screenshot.png
23
+
24
+ # target a repo explicitly, multiple files at once
25
+ npx ghimg upload --repo owner/repo a.png b.png
26
+
27
+ # verify the saved token
28
+ npx ghimg check
29
+ ```
30
+
31
+ Each upload prints `![name](https://github.com/user-attachments/assets/...)`.
32
+
33
+ ## Commands
34
+
35
+ | Command | Description |
36
+ | -------- | ---------------------------------------------------------------- |
37
+ | `init` | Setup wizard for your GitHub session token |
38
+ | `upload` | Upload one or more images and print markdown references |
39
+ | `check` | Verify the saved token and print the authenticated username |
40
+
41
+ All commands accept `--config <path>` to point at a specific `.env` file
42
+ (defaults to `.env` in the current directory). `upload` also takes
43
+ `--repo owner/repo`.
44
+
45
+ ## Requirements
46
+
47
+ - **Node 22+** — uses global `fetch`, `FormData`, and `Blob`.
48
+ - **[`gh` CLI](https://cli.github.com)**, authenticated — used to resolve the numeric
49
+ repository ID (works for private repos).
50
+
51
+ ## Security
52
+
53
+ This tool uses your `user_session` cookie, which is your full authenticated GitHub
54
+ web session — more powerful than a scoped token. It is only ever sent to
55
+ `github.com` and to the presigned S3 URL GitHub returns. The token is stored in
56
+ `.env` (gitignored); don't commit it.
57
+
58
+ ## License
59
+
60
+ MIT
package/bin/ghimg.js ADDED
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { init } from '../commands/init.js'
4
+ import { upload } from '../commands/upload.js'
5
+ import { check } from '../commands/check.js'
6
+
7
+ const command = process.argv[2]
8
+ const args = process.argv.slice(3)
9
+
10
+ const commands = {
11
+ init,
12
+ upload: () => upload(args),
13
+ check: () => check(args)
14
+ }
15
+
16
+ // A leading argument that is neither a flag nor a known command is treated as a
17
+ // file path, so `ghimg screenshot.png` is shorthand for `ghimg upload screenshot.png`.
18
+ function looksLikeFile(arg) {
19
+ return Boolean(arg) && !arg.startsWith('-')
20
+ }
21
+
22
+ async function main() {
23
+ try {
24
+ if (commands[command]) {
25
+ await commands[command]()
26
+ } else if (looksLikeFile(command)) {
27
+ await upload(process.argv.slice(2))
28
+ } else {
29
+ console.log(`
30
+ Usage: ghimg [command] <image-path>...
31
+
32
+ Commands:
33
+ init Setup wizard for your GitHub session token
34
+ upload [--repo owner/repo] Upload one or more images, print markdown
35
+ <image-path>... (repo defaults to the current git remote)
36
+ check Verify the saved token and print the username
37
+
38
+ The "upload" command is the default: a first argument that looks like a file is
39
+ uploaded directly. All commands accept --config <path> to point at a specific
40
+ .env file (defaults to .env in the current directory).
41
+
42
+ Examples:
43
+ npx ghimg init
44
+ npx ghimg screenshot.png
45
+ npx ghimg upload --repo owner/repo a.png b.png
46
+ npx ghimg check
47
+ `)
48
+ process.exit(command ? 1 : 0)
49
+ }
50
+ } catch (error) {
51
+ console.error('Error:', error.message)
52
+ process.exit(1)
53
+ }
54
+ }
55
+
56
+ main()
@@ -0,0 +1,13 @@
1
+ import { loadValidatedConfig } from '../lib/config.js'
2
+ import { parseArgs } from '../lib/args.js'
3
+ import { checkValidity } from '../lib/github.js'
4
+
5
+ export async function check(argv) {
6
+ const { options } = parseArgs(argv, { flags: ['--config'] })
7
+
8
+ const { sessionToken } = loadValidatedConfig(options.config)
9
+ const username = await checkValidity(sessionToken)
10
+
11
+ console.error('Token is valid')
12
+ if (username) console.log(username)
13
+ }
@@ -0,0 +1,33 @@
1
+ import { intro, outro, password, isCancel, cancel, log, spinner } from '@clack/prompts'
2
+ import { saveConfig } from '../lib/config.js'
3
+ import { checkValidity } from '../lib/github.js'
4
+
5
+ function required(value) {
6
+ return value ? undefined : 'Required'
7
+ }
8
+
9
+ export async function init() {
10
+ intro('ghimg setup')
11
+
12
+ log.info('Copy the "user_session" cookie from github.com:\n devtools -> Application -> Cookies -> https://github.com -> user_session')
13
+
14
+ const token = await password({
15
+ message: 'GitHub user_session cookie value',
16
+ validate: required
17
+ })
18
+ if (isCancel(token)) return cancel('Setup cancelled')
19
+
20
+ saveConfig({ sessionToken: token.trim() })
21
+
22
+ const s = spinner()
23
+ s.start('Validating token')
24
+ try {
25
+ const username = await checkValidity(token.trim())
26
+ s.stop(username ? `Authenticated as ${username}` : 'Token saved (could not read username)')
27
+ } catch (error) {
28
+ s.stop(`Saved, but validation failed: ${error.message}`)
29
+ }
30
+
31
+ log.success('Saved to .env')
32
+ outro('Done. Try: npx ghimg upload screenshot.png')
33
+ }
@@ -0,0 +1,31 @@
1
+ import { loadValidatedConfig } from '../lib/config.js'
2
+ import { parseArgs } from '../lib/args.js'
3
+ import { resolveRepo, getUploadToken, uploadAsset } from '../lib/github.js'
4
+
5
+ export async function upload(argv) {
6
+ const { options, positionals } = parseArgs(argv, {
7
+ flags: ['--repo', '--config']
8
+ })
9
+
10
+ if (positionals.length === 0) {
11
+ console.error('Usage: ghimg upload [--repo owner/repo] <image-path>...')
12
+ process.exit(1)
13
+ }
14
+
15
+ const { sessionToken } = loadValidatedConfig(options.config)
16
+ const info = await resolveRepo(options.repo)
17
+ const uploadToken = await getUploadToken(sessionToken, info.owner, info.name)
18
+
19
+ let failed = false
20
+ for (const path of positionals) {
21
+ try {
22
+ const { markdown } = await uploadAsset(sessionToken, info, uploadToken, path)
23
+ console.log(markdown)
24
+ } catch (error) {
25
+ console.error(`Error uploading ${path}: ${error.message}`)
26
+ failed = true
27
+ }
28
+ }
29
+
30
+ if (failed) process.exit(1)
31
+ }
package/lib/args.js ADDED
@@ -0,0 +1,22 @@
1
+ // Split argv into named options and bare positionals, so flags can appear in
2
+ // any order. `flags` take a following value; `booleans` are standalone.
3
+ // Option keys drop the leading '--' (e.g. '--repo' -> 'repo').
4
+ function parseArgs(argv, { flags = [], booleans = [] } = {}) {
5
+ const options = {}
6
+ const positionals = []
7
+
8
+ for (let i = 0; i < argv.length; i++) {
9
+ const arg = argv[i]
10
+ if (booleans.includes(arg)) {
11
+ options[arg.replace(/^--/, '')] = true
12
+ } else if (flags.includes(arg)) {
13
+ options[arg.replace(/^--/, '')] = argv[++i]
14
+ } else {
15
+ positionals.push(arg)
16
+ }
17
+ }
18
+
19
+ return { options, positionals }
20
+ }
21
+
22
+ export { parseArgs }
package/lib/config.js ADDED
@@ -0,0 +1,55 @@
1
+ import { z } from 'zod'
2
+ import { existsSync, writeFileSync } from 'fs'
3
+
4
+ const ENV_FILE = '.env'
5
+
6
+ const configSchema = z.object({
7
+ sessionToken: z.string().min(1)
8
+ })
9
+
10
+ // Load the given .env file (if present), then fall back to whatever is already
11
+ // in the environment.
12
+ function readEnv(envFile) {
13
+ if (existsSync(envFile)) process.loadEnvFile(envFile)
14
+ return process.env
15
+ }
16
+
17
+ function loadConfig(envFile = ENV_FILE) {
18
+ const env = readEnv(envFile)
19
+
20
+ if (!env.GH_SESSION_TOKEN) return null
21
+
22
+ const result = configSchema.safeParse({
23
+ sessionToken: env.GH_SESSION_TOKEN.trim()
24
+ })
25
+
26
+ if (!result.success) {
27
+ console.error('Configuration validation failed:')
28
+ console.error(z.prettifyError(result.error))
29
+ process.exit(1)
30
+ }
31
+
32
+ return result.data
33
+ }
34
+
35
+ function loadValidatedConfig(envFile = ENV_FILE) {
36
+ const config = loadConfig(envFile)
37
+
38
+ if (!config) {
39
+ console.error('No session token found. Run "npx ghimg init" first, or set GH_SESSION_TOKEN.')
40
+ process.exit(1)
41
+ }
42
+
43
+ return config
44
+ }
45
+
46
+ function saveConfig(config) {
47
+ try {
48
+ writeFileSync(ENV_FILE, `GH_SESSION_TOKEN="${config.sessionToken}"\n`)
49
+ } catch (error) {
50
+ console.error('Error writing .env file: ' + error.message)
51
+ process.exit(1)
52
+ }
53
+ }
54
+
55
+ export { loadConfig, loadValidatedConfig, saveConfig }
package/lib/github.js ADDED
@@ -0,0 +1,164 @@
1
+ import { execa } from 'execa'
2
+ import { readFileSync } from 'fs'
3
+ import { basename, extname } from 'path'
4
+
5
+ const GITHUB = 'https://github.com'
6
+ const USER_AGENT =
7
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' +
8
+ '(KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36'
9
+
10
+ const MIME = {
11
+ '.png': 'image/png',
12
+ '.jpg': 'image/jpeg',
13
+ '.jpeg': 'image/jpeg',
14
+ '.gif': 'image/gif',
15
+ '.webp': 'image/webp',
16
+ '.svg': 'image/svg+xml'
17
+ }
18
+
19
+ // GitHub requires both cookies for CSRF validation on the upload endpoint.
20
+ function cookieHeader(token) {
21
+ return `user_session=${token}; __Host-user_session_same_site=${token}`
22
+ }
23
+
24
+ function ghHeaders(token, owner, repo, extra = {}) {
25
+ return {
26
+ Cookie: cookieHeader(token),
27
+ 'User-Agent': USER_AGENT,
28
+ Origin: GITHUB,
29
+ Referer: `${GITHUB}/${owner}/${repo}`,
30
+ 'X-Requested-With': 'XMLHttpRequest',
31
+ ...extra
32
+ }
33
+ }
34
+
35
+ // Resolve owner/name (from --repo or the git remote) plus the numeric repo ID.
36
+ // The ID is looked up via the gh CLI, so private repos resolve too.
37
+ async function resolveRepo(repoFlag) {
38
+ let slug = repoFlag
39
+
40
+ if (!slug) {
41
+ let url
42
+ try {
43
+ const { stdout } = await execa('git', ['remote', 'get-url', 'origin'])
44
+ url = stdout.trim()
45
+ } catch {
46
+ throw new Error('not a git repository — pass --repo owner/repo')
47
+ }
48
+ const match = url.match(/github\.com[:/](.+?)\/(.+?)(?:\.git)?$/)
49
+ slug = match ? `${match[1]}/${match[2]}` : ''
50
+ }
51
+
52
+ const [owner, name] = slug.split('/')
53
+ if (!owner || !name) throw new Error(`could not parse owner/repo from: ${slug || '(empty)'}`)
54
+
55
+ let id
56
+ try {
57
+ const { stdout } = await execa('gh', ['api', `repos/${owner}/${name}`, '--jq', '.id'])
58
+ id = Number(stdout.trim())
59
+ } catch {
60
+ throw new Error(`failed to look up repo ID for ${owner}/${name} (is the gh CLI installed and authenticated?)`)
61
+ }
62
+ if (!id) throw new Error(`unexpected repo ID for ${owner}/${name}`)
63
+
64
+ return { owner, name, id }
65
+ }
66
+
67
+ // Verify a session token by loading the profile page; returns the username.
68
+ async function checkValidity(token) {
69
+ const response = await fetch(`${GITHUB}/settings/profile`, {
70
+ headers: { Cookie: cookieHeader(token), 'User-Agent': USER_AGENT },
71
+ redirect: 'manual'
72
+ })
73
+ if (response.status !== 200) {
74
+ throw new Error(`token is invalid or expired (status ${response.status})`)
75
+ }
76
+ const match = (await response.text()).match(/<meta name="user-login" content="([^"]+)"/)
77
+ return match ? match[1] : ''
78
+ }
79
+
80
+ // Step 0: scrape the per-repo uploadToken from the repo page.
81
+ async function getUploadToken(token, owner, repo) {
82
+ const response = await fetch(`${GITHUB}/${owner}/${repo}`, {
83
+ headers: { Cookie: cookieHeader(token), 'User-Agent': USER_AGENT }
84
+ })
85
+ if (!response.ok) {
86
+ throw new Error(`repo page ${response.status} — do you have access to ${owner}/${repo}?`)
87
+ }
88
+ const match = (await response.text()).match(/"uploadToken":"([^"]+)"/)
89
+ if (!match) {
90
+ throw new Error(
91
+ `uploadToken not found — need write access to ${owner}/${repo} ` +
92
+ `(or, if it enforces SAML SSO, authorize at ${GITHUB}/orgs/${owner}/sso)`
93
+ )
94
+ }
95
+ return match[1]
96
+ }
97
+
98
+ // Step 1: request the S3 upload policy.
99
+ async function requestPolicy(token, info, uploadToken, file, size, contentType) {
100
+ const form = new FormData()
101
+ form.set('name', file)
102
+ form.set('size', String(size))
103
+ form.set('content_type', contentType)
104
+ form.set('authenticity_token', uploadToken)
105
+ form.set('repository_id', String(info.id))
106
+
107
+ const response = await fetch(`${GITHUB}/upload/policies/assets`, {
108
+ method: 'POST',
109
+ body: form,
110
+ headers: ghHeaders(token, info.owner, info.name, { Accept: 'application/json' })
111
+ })
112
+ if (response.status !== 201) {
113
+ throw new Error(`policy request ${response.status}: ${(await response.text()).slice(0, 200)}`)
114
+ }
115
+ return response.json()
116
+ }
117
+
118
+ // Step 2: upload the bytes to the presigned S3 URL (no GitHub cookies here).
119
+ async function uploadToS3(policy, buffer, file, contentType) {
120
+ const form = new FormData()
121
+ // Server-provided field order; the file part must come last.
122
+ for (const [key, value] of Object.entries(policy.form)) form.set(key, value)
123
+ form.set('file', new Blob([buffer], { type: contentType }), file)
124
+
125
+ const response = await fetch(policy.upload_url, {
126
+ method: 'POST',
127
+ body: form,
128
+ headers: { Origin: GITHUB, 'User-Agent': USER_AGENT }
129
+ })
130
+ if (![200, 201, 204].includes(response.status)) {
131
+ throw new Error(`S3 upload ${response.status}: ${(await response.text()).slice(0, 300)}`)
132
+ }
133
+ }
134
+
135
+ // Step 3: finalize the asset and return its public reference.
136
+ async function finalizeUpload(token, info, policy) {
137
+ const form = new FormData()
138
+ form.set('authenticity_token', policy.asset_upload_authenticity_token)
139
+
140
+ const response = await fetch(`${GITHUB}/upload/assets/${policy.asset.id}`, {
141
+ method: 'PUT',
142
+ body: form,
143
+ headers: ghHeaders(token, info.owner, info.name, { Accept: 'application/json' })
144
+ })
145
+ if (response.status !== 200) {
146
+ throw new Error(`finalize ${response.status}: ${(await response.text()).slice(0, 200)}`)
147
+ }
148
+ return response.json()
149
+ }
150
+
151
+ // Upload one image through the full flow and return its markdown reference.
152
+ async function uploadAsset(token, info, uploadToken, path) {
153
+ const buffer = readFileSync(path)
154
+ const file = basename(path)
155
+ const contentType = MIME[extname(path).toLowerCase()] ?? 'application/octet-stream'
156
+
157
+ const policy = await requestPolicy(token, info, uploadToken, file, buffer.length, contentType)
158
+ await uploadToS3(policy, buffer, file, contentType)
159
+ const { href, name } = await finalizeUpload(token, info, policy)
160
+
161
+ return { markdown: `![${name}](${href})`, url: href, name }
162
+ }
163
+
164
+ export { resolveRepo, checkValidity, getUploadToken, uploadAsset }
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "ghimg",
3
+ "version": "0.1.0",
4
+ "description": "Upload images to GitHub from the command line and print markdown references.",
5
+ "type": "module",
6
+ "bin": {
7
+ "ghimg": "bin/ghimg.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "commands",
12
+ "lib"
13
+ ],
14
+ "scripts": {
15
+ "lint": "eslint .",
16
+ "lint:fix": "eslint . --fix"
17
+ },
18
+ "keywords": [
19
+ "github",
20
+ "image",
21
+ "upload",
22
+ "markdown",
23
+ "cli",
24
+ "user-attachments"
25
+ ],
26
+ "author": "Nico Devs <hi@nicodevs.com>",
27
+ "license": "MIT",
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/nicodevs/ghimg.git"
31
+ },
32
+ "bugs": {
33
+ "url": "https://github.com/nicodevs/ghimg/issues"
34
+ },
35
+ "homepage": "https://github.com/nicodevs/ghimg#readme",
36
+ "engines": {
37
+ "node": ">=22"
38
+ },
39
+ "dependencies": {
40
+ "@clack/prompts": "^0.11.0",
41
+ "execa": "^9.0.0",
42
+ "zod": "^4.3.4"
43
+ },
44
+ "devDependencies": {
45
+ "@eslint/js": "^9.39.2",
46
+ "eslint": "^9.39.2",
47
+ "globals": "^17.3.0"
48
+ }
49
+ }