@orangemust/unsplash-cli 0.0.0-stage → 0.1.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/AGENTS.md +33 -0
- package/LICENSE +21 -0
- package/README.md +125 -2
- package/bin/unsplash.js +7 -0
- package/dist/api.js +99 -0
- package/dist/auth.js +100 -0
- package/dist/index.js +335 -0
- package/dist/photos.js +101 -0
- package/package.json +49 -4
package/AGENTS.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# unsplash-cli
|
|
2
|
+
|
|
3
|
+
Agent notes for `@orangemust/unsplash-cli`.
|
|
4
|
+
|
|
5
|
+
## Commands
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
unsplash auth status --json
|
|
9
|
+
unsplash auth set --stdin # pbpaste | unsplash auth set --stdin
|
|
10
|
+
unsplash auth clear
|
|
11
|
+
unsplash search "<query>" --json
|
|
12
|
+
unsplash search "<query>" --download 2 --out-dir <dir> --json
|
|
13
|
+
unsplash download <id|url> --out <file> --json
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Rules
|
|
17
|
+
|
|
18
|
+
- Never print, echo or log the Access Key.
|
|
19
|
+
- Key lives in macOS Keychain (service `unsplash-cli`); `UNSPLASH_ACCESS_KEY` is an env override for CI only.
|
|
20
|
+
- Do not create `.env` files for this project.
|
|
21
|
+
- `--json` is the agent-facing output; human output is for terminals.
|
|
22
|
+
- Downloads write a `<photo-id>.json` sidecar and trigger Unsplash download tracking automatically.
|
|
23
|
+
- Keep attribution (`Photo by <name> on Unsplash`) when an image is published.
|
|
24
|
+
- 401/403/429 are not retried automatically; report the error.
|
|
25
|
+
- Tests: `npm test` (local mock API, no Keychain/network).
|
|
26
|
+
|
|
27
|
+
## Build
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm run typecheck
|
|
31
|
+
npm test
|
|
32
|
+
npm run build
|
|
33
|
+
```
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 mack-peng
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,126 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @orangemust/unsplash-cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Search and download [Unsplash](https://unsplash.com) photos from the command line.
|
|
4
|
+
|
|
5
|
+
- Search with orientation / color / order filters
|
|
6
|
+
- Download the original or a specific size
|
|
7
|
+
- Writes a `<photo-id>.json` sidecar with photographer, source page and license
|
|
8
|
+
- Tracks downloads as required by the Unsplash API guidelines
|
|
9
|
+
- **Access Key stored in macOS Keychain** — never in `.env`, project files or shell history
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# from this directory
|
|
15
|
+
npm install
|
|
16
|
+
npm run build
|
|
17
|
+
npm link
|
|
18
|
+
|
|
19
|
+
# or after publishing
|
|
20
|
+
npm install -g @orangemust/unsplash-cli
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
> `unsplash-cli` on npm is an unrelated, unmaintained 2015 package. This project uses the scoped name `@orangemust/unsplash-cli`; the command is `unsplash` (alias `unsplash-cli`).
|
|
24
|
+
|
|
25
|
+
## Authentication
|
|
26
|
+
|
|
27
|
+
Get an Access Key from <https://unsplash.com/oauth/applications> (New application → Access Key, not the Secret Key).
|
|
28
|
+
|
|
29
|
+
Store it in the macOS Keychain:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
unsplash auth set
|
|
33
|
+
# or paste from clipboard without putting the key in shell history
|
|
34
|
+
pbpaste | unsplash auth set --stdin
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Check status without printing the key:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
unsplash auth status
|
|
41
|
+
# Configured: yes
|
|
42
|
+
# Source: macOS Keychain (service: unsplash-cli)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Remove it:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
unsplash auth clear
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The key is stored as a generic password with service `unsplash-cli` and account `default`. It is read with the macOS `security` command; the first read may show a Keychain access prompt — choose **Always Allow** if you do not want to see it again.
|
|
52
|
+
|
|
53
|
+
For CI or non-macOS environments, set `UNSPLASH_ACCESS_KEY`; it takes precedence over Keychain storage.
|
|
54
|
+
|
|
55
|
+
## Usage
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
# Search
|
|
59
|
+
unsplash search "minimal desk" --per-page 6
|
|
60
|
+
unsplash search "minimal desk" --orientation portrait --color white --json
|
|
61
|
+
|
|
62
|
+
# Download one photo by id or Unsplash URL
|
|
63
|
+
unsplash download abc123XYZ --out ./assets/desk.jpg
|
|
64
|
+
unsplash download https://unsplash.com/photos/some-slug-abc123XYZ --out-dir ./assets
|
|
65
|
+
|
|
66
|
+
# Search and download the first two results
|
|
67
|
+
unsplash search "minimal desk" --download 2 --out-dir ./assets/refs
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Options
|
|
71
|
+
|
|
72
|
+
`search`:
|
|
73
|
+
|
|
74
|
+
- `--per-page N` — 1–30, default 6
|
|
75
|
+
- `--page N` — default 1
|
|
76
|
+
- `--orientation landscape|portrait|squarish`
|
|
77
|
+
- `--color black_and_white|black|white|yellow|orange|red|purple|magenta|green|teal|blue`
|
|
78
|
+
- `--order-by relevant|latest`
|
|
79
|
+
- `--download N` — requires `--out-dir`
|
|
80
|
+
- `--out-dir DIR`
|
|
81
|
+
- `--size raw|full|regular|small|thumb` — default `full`
|
|
82
|
+
- `--json`
|
|
83
|
+
|
|
84
|
+
`download`:
|
|
85
|
+
|
|
86
|
+
- `--out FILE` or `--out-dir DIR`
|
|
87
|
+
- `--size raw|full|regular|small|thumb` — default `full`
|
|
88
|
+
- `--json`
|
|
89
|
+
|
|
90
|
+
## Output and compliance
|
|
91
|
+
|
|
92
|
+
Human output lists the photo id, dimensions, description, photographer credit and source page. `--json` returns a compact object for agents.
|
|
93
|
+
|
|
94
|
+
Each download saves the image plus a sidecar:
|
|
95
|
+
|
|
96
|
+
```json
|
|
97
|
+
{
|
|
98
|
+
"provider": "unsplash",
|
|
99
|
+
"id": "abc123XYZ",
|
|
100
|
+
"photographer": { "name": "Jane Doe", "profile_url": "https://unsplash.com/@jane?utm_source=unsplash-cli&utm_medium=referral" },
|
|
101
|
+
"photo_page": "https://unsplash.com/photos/abc123XYZ?utm_source=unsplash-cli&utm_medium=referral",
|
|
102
|
+
"attribution": "Photo by Jane Doe on Unsplash (...)",
|
|
103
|
+
"download_tracked": true,
|
|
104
|
+
"license": "https://unsplash.com/license"
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
When publishing an image, keep the attribution and link. The Unsplash License allows free commercial use, but does not allow selling unmodified copies or building a competing image service.
|
|
109
|
+
|
|
110
|
+
The CLI calls Unsplash's `download_location` endpoint after saving an image. If tracking fails it warns but keeps the file. API errors (401/403/429) are reported without retrying.
|
|
111
|
+
|
|
112
|
+
## Development
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
npm install
|
|
116
|
+
npm run typecheck
|
|
117
|
+
npm test
|
|
118
|
+
npm run build
|
|
119
|
+
node bin/unsplash.js --help
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Tests use a local mock API (`UNSPLASH_API_BASE`) and never touch the real Keychain or network.
|
|
123
|
+
|
|
124
|
+
## License
|
|
125
|
+
|
|
126
|
+
MIT
|
package/bin/unsplash.js
ADDED
package/dist/api.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.UnsplashApi = exports.ApiError = void 0;
|
|
4
|
+
class ApiError extends Error {
|
|
5
|
+
status;
|
|
6
|
+
constructor(status, message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.status = status;
|
|
9
|
+
this.name = 'ApiError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
exports.ApiError = ApiError;
|
|
13
|
+
class UnsplashApi {
|
|
14
|
+
key;
|
|
15
|
+
base;
|
|
16
|
+
lastRate = { limit: null, remaining: null };
|
|
17
|
+
constructor(key, base = process.env.UNSPLASH_API_BASE || 'https://api.unsplash.com') {
|
|
18
|
+
this.key = key;
|
|
19
|
+
this.base = base.replace(/\/+$/, '');
|
|
20
|
+
}
|
|
21
|
+
headers() {
|
|
22
|
+
return {
|
|
23
|
+
Authorization: `Client-ID ${this.key}`,
|
|
24
|
+
'Accept-Version': 'v1',
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
updateRate(response) {
|
|
28
|
+
this.lastRate = {
|
|
29
|
+
limit: response.headers.get('x-ratelimit-limit') ?? this.lastRate.limit,
|
|
30
|
+
remaining: response.headers.get('x-ratelimit-remaining') ?? this.lastRate.remaining,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
async request(path, params = {}) {
|
|
34
|
+
const url = new URL(this.base + path);
|
|
35
|
+
for (const [name, value] of Object.entries(params)) {
|
|
36
|
+
if (value !== undefined && value !== '')
|
|
37
|
+
url.searchParams.set(name, String(value));
|
|
38
|
+
}
|
|
39
|
+
const response = await fetch(url, { headers: this.headers() });
|
|
40
|
+
this.updateRate(response);
|
|
41
|
+
if (!response.ok)
|
|
42
|
+
throw await toApiError(response);
|
|
43
|
+
return (await response.json());
|
|
44
|
+
}
|
|
45
|
+
search(query, options = {}) {
|
|
46
|
+
return this.request('/search/photos', {
|
|
47
|
+
query,
|
|
48
|
+
per_page: options.perPage,
|
|
49
|
+
page: options.page,
|
|
50
|
+
orientation: options.orientation,
|
|
51
|
+
color: options.color,
|
|
52
|
+
order_by: options.orderBy,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
photo(id) {
|
|
56
|
+
return this.request(`/photos/${encodeURIComponent(id)}`);
|
|
57
|
+
}
|
|
58
|
+
random() {
|
|
59
|
+
return this.request('/photos/random');
|
|
60
|
+
}
|
|
61
|
+
async track(downloadLocation) {
|
|
62
|
+
if (!downloadLocation)
|
|
63
|
+
return false;
|
|
64
|
+
try {
|
|
65
|
+
const response = await fetch(downloadLocation, { headers: this.headers() });
|
|
66
|
+
return response.ok;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async fetchImage(url) {
|
|
73
|
+
const response = await fetch(url);
|
|
74
|
+
if (!response.ok)
|
|
75
|
+
throw new Error(`Image download failed (HTTP ${response.status}).`);
|
|
76
|
+
return {
|
|
77
|
+
buffer: Buffer.from(await response.arrayBuffer()),
|
|
78
|
+
contentType: response.headers.get('content-type') ?? '',
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
exports.UnsplashApi = UnsplashApi;
|
|
83
|
+
async function toApiError(response) {
|
|
84
|
+
let detail = '';
|
|
85
|
+
try {
|
|
86
|
+
const body = (await response.json());
|
|
87
|
+
detail = body.errors?.join('; ') || body.error || '';
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// ignore non-JSON error bodies
|
|
91
|
+
}
|
|
92
|
+
if (response.status === 401) {
|
|
93
|
+
return new ApiError(401, 'Invalid Unsplash Access Key (401). Run `unsplash auth set` to update it.');
|
|
94
|
+
}
|
|
95
|
+
if (response.status === 403 || response.status === 429) {
|
|
96
|
+
return new ApiError(response.status, `Unsplash rate limit reached (${response.status}). Try again later.`);
|
|
97
|
+
}
|
|
98
|
+
return new ApiError(response.status, `Unsplash API error ${response.status}${detail ? `: ${detail}` : ''}.`);
|
|
99
|
+
}
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.keychainSupported = keychainSupported;
|
|
4
|
+
exports.keychainStatus = keychainStatus;
|
|
5
|
+
exports.readKeychainKey = readKeychainKey;
|
|
6
|
+
exports.writeKeychainKey = writeKeychainKey;
|
|
7
|
+
exports.deleteKeychainKey = deleteKeychainKey;
|
|
8
|
+
exports.envKey = envKey;
|
|
9
|
+
exports.resolveKey = resolveKey;
|
|
10
|
+
exports.readSecret = readSecret;
|
|
11
|
+
const node_child_process_1 = require("node:child_process");
|
|
12
|
+
const node_os_1 = require("node:os");
|
|
13
|
+
const node_readline_1 = require("node:readline");
|
|
14
|
+
const SERVICE = 'unsplash-cli';
|
|
15
|
+
const ACCOUNT = 'default';
|
|
16
|
+
function keychainSupported() {
|
|
17
|
+
return (0, node_os_1.platform)() === 'darwin';
|
|
18
|
+
}
|
|
19
|
+
function keychainStatus() {
|
|
20
|
+
if (!keychainSupported())
|
|
21
|
+
return 'unsupported';
|
|
22
|
+
try {
|
|
23
|
+
(0, node_child_process_1.execFileSync)('security', ['find-generic-password', '-s', SERVICE, '-a', ACCOUNT], {
|
|
24
|
+
stdio: 'ignore',
|
|
25
|
+
});
|
|
26
|
+
return 'configured';
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return 'missing';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function readKeychainKey() {
|
|
33
|
+
if (!keychainSupported())
|
|
34
|
+
return null;
|
|
35
|
+
try {
|
|
36
|
+
const key = (0, node_child_process_1.execFileSync)('security', ['find-generic-password', '-s', SERVICE, '-a', ACCOUNT, '-w'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
37
|
+
return key || null;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function writeKeychainKey(key) {
|
|
44
|
+
if (!keychainSupported()) {
|
|
45
|
+
throw new Error('macOS Keychain is not available on this platform. Use UNSPLASH_ACCESS_KEY instead.');
|
|
46
|
+
}
|
|
47
|
+
(0, node_child_process_1.execFileSync)('security', ['add-generic-password', '-U', '-s', SERVICE, '-a', ACCOUNT, '-w', key], { stdio: 'ignore' });
|
|
48
|
+
}
|
|
49
|
+
function deleteKeychainKey() {
|
|
50
|
+
if (!keychainSupported())
|
|
51
|
+
return false;
|
|
52
|
+
try {
|
|
53
|
+
(0, node_child_process_1.execFileSync)('security', ['delete-generic-password', '-s', SERVICE, '-a', ACCOUNT], {
|
|
54
|
+
stdio: 'ignore',
|
|
55
|
+
});
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function envKey() {
|
|
63
|
+
const key = process.env.UNSPLASH_ACCESS_KEY?.trim();
|
|
64
|
+
return key || null;
|
|
65
|
+
}
|
|
66
|
+
function resolveKey() {
|
|
67
|
+
const env = envKey();
|
|
68
|
+
if (env)
|
|
69
|
+
return { key: env, source: 'env' };
|
|
70
|
+
const keychain = readKeychainKey();
|
|
71
|
+
if (keychain)
|
|
72
|
+
return { key: keychain, source: 'keychain' };
|
|
73
|
+
throw new Error('Unsplash Access Key is not configured. Run `unsplash auth set`.');
|
|
74
|
+
}
|
|
75
|
+
async function readSecret(label, forceStdin = false) {
|
|
76
|
+
if (forceStdin || !process.stdin.isTTY) {
|
|
77
|
+
if (forceStdin && process.stdin.isTTY) {
|
|
78
|
+
throw new Error('--stdin requires piped input. Pipe the key or omit --stdin.');
|
|
79
|
+
}
|
|
80
|
+
const chunks = [];
|
|
81
|
+
for await (const chunk of process.stdin)
|
|
82
|
+
chunks.push(Buffer.from(chunk));
|
|
83
|
+
return Buffer.concat(chunks).toString('utf8').trim();
|
|
84
|
+
}
|
|
85
|
+
process.stdout.write(label);
|
|
86
|
+
return new Promise((resolve, reject) => {
|
|
87
|
+
const rl = (0, node_readline_1.createInterface)({ input: process.stdin, output: process.stdout, terminal: true });
|
|
88
|
+
rl._writeToOutput = () => { };
|
|
89
|
+
rl.question('', (value) => {
|
|
90
|
+
rl.close();
|
|
91
|
+
process.stdout.write('\n');
|
|
92
|
+
resolve(value.trim());
|
|
93
|
+
});
|
|
94
|
+
rl.on('SIGINT', () => {
|
|
95
|
+
rl.close();
|
|
96
|
+
process.stdout.write('\n');
|
|
97
|
+
reject(new Error('Cancelled.'));
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.main = main;
|
|
4
|
+
const node_fs_1 = require("node:fs");
|
|
5
|
+
const node_path_1 = require("node:path");
|
|
6
|
+
const node_util_1 = require("node:util");
|
|
7
|
+
const api_1 = require("./api");
|
|
8
|
+
const auth_1 = require("./auth");
|
|
9
|
+
const photos_1 = require("./photos");
|
|
10
|
+
const HELP = `unsplash — search and download Unsplash photos
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
unsplash auth set [--stdin] [--skip-verify]
|
|
14
|
+
unsplash auth status [--json]
|
|
15
|
+
unsplash auth clear
|
|
16
|
+
unsplash search "<query>" [options]
|
|
17
|
+
unsplash download <id|url> [options]
|
|
18
|
+
unsplash --version
|
|
19
|
+
|
|
20
|
+
Security:
|
|
21
|
+
The Access Key is stored in the macOS Keychain (service "unsplash-cli").
|
|
22
|
+
It is never written to .env or project files. The UNSPLASH_ACCESS_KEY
|
|
23
|
+
environment variable overrides Keychain storage for CI.
|
|
24
|
+
|
|
25
|
+
Run \`unsplash <command> --help\` for command options.`;
|
|
26
|
+
const AUTH_HELP = `Usage:
|
|
27
|
+
unsplash auth set [--stdin] [--skip-verify] [--json]
|
|
28
|
+
unsplash auth status [--json]
|
|
29
|
+
unsplash auth clear [--json]
|
|
30
|
+
|
|
31
|
+
set Store the Access Key in macOS Keychain (prompts, input hidden).
|
|
32
|
+
Use --stdin to read from a pipe: pbpaste | unsplash auth set --stdin
|
|
33
|
+
status Show whether a key is configured; never prints the key.
|
|
34
|
+
clear Remove the Keychain entry.`;
|
|
35
|
+
const SEARCH_HELP = `Usage:
|
|
36
|
+
unsplash search "<query>" [options]
|
|
37
|
+
|
|
38
|
+
Options:
|
|
39
|
+
--per-page N Results per page (1-30, default 6)
|
|
40
|
+
--page N Page number (default 1)
|
|
41
|
+
--orientation X landscape | portrait | squarish
|
|
42
|
+
--color X black_and_white, black, white, yellow, orange,
|
|
43
|
+
red, purple, magenta, green, teal, blue
|
|
44
|
+
--order-by X relevant | latest (default relevant)
|
|
45
|
+
--download N Download the first N results (requires --out-dir)
|
|
46
|
+
--out-dir DIR Directory for downloaded images
|
|
47
|
+
--size X raw | full | regular | small | thumb (default full)
|
|
48
|
+
--json Machine-readable JSON
|
|
49
|
+
--help Show this help`;
|
|
50
|
+
const DOWNLOAD_HELP = `Usage:
|
|
51
|
+
unsplash download <id|url> [options]
|
|
52
|
+
|
|
53
|
+
Options:
|
|
54
|
+
--out FILE Exact output file path
|
|
55
|
+
--out-dir DIR Directory; file is named <photo-id>.<ext>
|
|
56
|
+
--size X raw | full | regular | small | thumb (default full)
|
|
57
|
+
--json Machine-readable JSON
|
|
58
|
+
--help Show this help
|
|
59
|
+
|
|
60
|
+
Each download writes a <photo-id>.json sidecar with photographer,
|
|
61
|
+
source page, license and download-tracking status.`;
|
|
62
|
+
async function main() {
|
|
63
|
+
const argv = process.argv.slice(2);
|
|
64
|
+
if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
|
|
65
|
+
console.log(HELP);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (argv[0] === '--version' || argv[0] === '-v') {
|
|
69
|
+
console.log(version());
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const command = argv.shift();
|
|
73
|
+
switch (command) {
|
|
74
|
+
case 'auth':
|
|
75
|
+
await runAuth(argv);
|
|
76
|
+
return;
|
|
77
|
+
case 'search':
|
|
78
|
+
await runSearch(argv);
|
|
79
|
+
return;
|
|
80
|
+
case 'download':
|
|
81
|
+
await runDownload(argv);
|
|
82
|
+
return;
|
|
83
|
+
default:
|
|
84
|
+
throw new Error(`Unknown command: ${command}. Run \`unsplash --help\`.`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
async function runAuth(argv) {
|
|
88
|
+
const sub = argv[0];
|
|
89
|
+
if (!sub || sub === '--help' || sub === '-h' || argv.includes('--help') || argv.includes('-h')) {
|
|
90
|
+
console.log(AUTH_HELP);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (sub === 'set') {
|
|
94
|
+
const { values } = (0, node_util_1.parseArgs)({
|
|
95
|
+
args: argv.slice(1),
|
|
96
|
+
options: {
|
|
97
|
+
stdin: { type: 'boolean' },
|
|
98
|
+
'skip-verify': { type: 'boolean' },
|
|
99
|
+
json: { type: 'boolean' },
|
|
100
|
+
},
|
|
101
|
+
allowPositionals: false,
|
|
102
|
+
strict: true,
|
|
103
|
+
});
|
|
104
|
+
const key = await (0, auth_1.readSecret)('Unsplash Access Key: ', Boolean(values.stdin));
|
|
105
|
+
if (!key)
|
|
106
|
+
throw new Error('No key provided.');
|
|
107
|
+
if (!values['skip-verify']) {
|
|
108
|
+
const api = new api_1.UnsplashApi(key);
|
|
109
|
+
try {
|
|
110
|
+
await api.random();
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (error instanceof api_1.ApiError && error.status === 403) {
|
|
114
|
+
console.error('warning: key could not be verified because the rate limit is exhausted; saving anyway.');
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
(0, auth_1.writeKeychainKey)(key);
|
|
122
|
+
if (values.json) {
|
|
123
|
+
console.log(JSON.stringify({ configured: true, source: 'keychain' }, null, 2));
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
console.log('Saved Unsplash Access Key to macOS Keychain (service: unsplash-cli).');
|
|
127
|
+
}
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (sub === 'status') {
|
|
131
|
+
const { values } = (0, node_util_1.parseArgs)({
|
|
132
|
+
args: argv.slice(1),
|
|
133
|
+
options: { json: { type: 'boolean' } },
|
|
134
|
+
allowPositionals: false,
|
|
135
|
+
strict: true,
|
|
136
|
+
});
|
|
137
|
+
const env = Boolean((0, auth_1.envKey)());
|
|
138
|
+
const keychain = (0, auth_1.keychainStatus)();
|
|
139
|
+
const source = env ? 'env' : keychain === 'configured' ? 'keychain' : null;
|
|
140
|
+
const configured = source !== null;
|
|
141
|
+
if (values.json) {
|
|
142
|
+
console.log(JSON.stringify({ configured, source, env, keychain }, null, 2));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
console.log(`Configured: ${configured ? 'yes' : 'no'}`);
|
|
146
|
+
if (configured) {
|
|
147
|
+
console.log(`Source: ${source === 'env' ? 'UNSPLASH_ACCESS_KEY environment variable' : 'macOS Keychain (service: unsplash-cli)'}`);
|
|
148
|
+
}
|
|
149
|
+
console.log(`Keychain: ${keychain}`);
|
|
150
|
+
console.log(`Environment: ${env ? 'set' : 'not set'}`);
|
|
151
|
+
if (!configured)
|
|
152
|
+
console.log('\nRun: unsplash auth set');
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (sub === 'clear') {
|
|
156
|
+
const { values } = (0, node_util_1.parseArgs)({
|
|
157
|
+
args: argv.slice(1),
|
|
158
|
+
options: { json: { type: 'boolean' } },
|
|
159
|
+
allowPositionals: false,
|
|
160
|
+
strict: true,
|
|
161
|
+
});
|
|
162
|
+
const removed = (0, auth_1.deleteKeychainKey)();
|
|
163
|
+
if (values.json) {
|
|
164
|
+
console.log(JSON.stringify({ cleared: removed }, null, 2));
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
console.log(removed
|
|
168
|
+
? 'Removed Unsplash Access Key from macOS Keychain.'
|
|
169
|
+
: 'No Keychain entry found.');
|
|
170
|
+
if ((0, auth_1.envKey)()) {
|
|
171
|
+
console.error('warning: UNSPLASH_ACCESS_KEY is still set in this environment and will take precedence.');
|
|
172
|
+
}
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
throw new Error(`Unknown auth command: ${sub}. Run \`unsplash auth --help\`.`);
|
|
176
|
+
}
|
|
177
|
+
async function runSearch(argv) {
|
|
178
|
+
const { values, positionals } = (0, node_util_1.parseArgs)({
|
|
179
|
+
args: argv,
|
|
180
|
+
options: {
|
|
181
|
+
'per-page': { type: 'string' },
|
|
182
|
+
page: { type: 'string' },
|
|
183
|
+
orientation: { type: 'string' },
|
|
184
|
+
color: { type: 'string' },
|
|
185
|
+
'order-by': { type: 'string' },
|
|
186
|
+
download: { type: 'string' },
|
|
187
|
+
'out-dir': { type: 'string' },
|
|
188
|
+
size: { type: 'string' },
|
|
189
|
+
json: { type: 'boolean' },
|
|
190
|
+
help: { type: 'boolean', short: 'h' },
|
|
191
|
+
},
|
|
192
|
+
allowPositionals: true,
|
|
193
|
+
strict: true,
|
|
194
|
+
});
|
|
195
|
+
if (values.help) {
|
|
196
|
+
console.log(SEARCH_HELP);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const query = positionals.join(' ').trim();
|
|
200
|
+
if (!query)
|
|
201
|
+
throw new Error('search requires a query.');
|
|
202
|
+
const perPage = intOption(values['per-page'], '--per-page', 6, 1, 30);
|
|
203
|
+
const page = intOption(values.page, '--page', 1, 1, Number.MAX_SAFE_INTEGER);
|
|
204
|
+
const downloadCount = intOption(values.download, '--download', 0, 0, 30);
|
|
205
|
+
const size = sizeOption(values.size);
|
|
206
|
+
const outDir = values['out-dir'];
|
|
207
|
+
if (downloadCount > 0 && !outDir)
|
|
208
|
+
throw new Error('--out-dir is required with --download.');
|
|
209
|
+
const { key } = (0, auth_1.resolveKey)();
|
|
210
|
+
const api = new api_1.UnsplashApi(key);
|
|
211
|
+
const data = await api.search(query, {
|
|
212
|
+
perPage,
|
|
213
|
+
page,
|
|
214
|
+
orientation: values.orientation,
|
|
215
|
+
color: values.color,
|
|
216
|
+
orderBy: values['order-by'],
|
|
217
|
+
});
|
|
218
|
+
const results = data.results.map((photo) => (0, photos_1.compactPhoto)(photo, query));
|
|
219
|
+
const downloads = [];
|
|
220
|
+
if (downloadCount > 0 && outDir) {
|
|
221
|
+
for (const result of results.slice(0, downloadCount)) {
|
|
222
|
+
downloads.push(await (0, photos_1.downloadPhoto)(api, result.id, { outDir, size }));
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (values.json) {
|
|
226
|
+
console.log(JSON.stringify({
|
|
227
|
+
query,
|
|
228
|
+
page,
|
|
229
|
+
total: data.total,
|
|
230
|
+
totalPages: data.total_pages,
|
|
231
|
+
rate: api.lastRate,
|
|
232
|
+
results,
|
|
233
|
+
...(downloads.length ? { downloads } : {}),
|
|
234
|
+
}, null, 2));
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
printSearch({
|
|
238
|
+
query,
|
|
239
|
+
page,
|
|
240
|
+
total: data.total,
|
|
241
|
+
totalPages: data.total_pages,
|
|
242
|
+
rate: api.lastRate,
|
|
243
|
+
results,
|
|
244
|
+
downloads,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
async function runDownload(argv) {
|
|
248
|
+
const { values, positionals } = (0, node_util_1.parseArgs)({
|
|
249
|
+
args: argv,
|
|
250
|
+
options: {
|
|
251
|
+
out: { type: 'string' },
|
|
252
|
+
'out-dir': { type: 'string' },
|
|
253
|
+
size: { type: 'string' },
|
|
254
|
+
json: { type: 'boolean' },
|
|
255
|
+
help: { type: 'boolean', short: 'h' },
|
|
256
|
+
},
|
|
257
|
+
allowPositionals: true,
|
|
258
|
+
strict: true,
|
|
259
|
+
});
|
|
260
|
+
if (values.help) {
|
|
261
|
+
console.log(DOWNLOAD_HELP);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const ref = positionals[0];
|
|
265
|
+
if (!ref)
|
|
266
|
+
throw new Error('download requires a photo id or URL.');
|
|
267
|
+
if (!values.out && !values['out-dir']) {
|
|
268
|
+
throw new Error('download requires --out or --out-dir.');
|
|
269
|
+
}
|
|
270
|
+
const size = sizeOption(values.size);
|
|
271
|
+
const { key } = (0, auth_1.resolveKey)();
|
|
272
|
+
const api = new api_1.UnsplashApi(key);
|
|
273
|
+
const record = await (0, photos_1.downloadPhoto)(api, ref, {
|
|
274
|
+
out: values.out,
|
|
275
|
+
outDir: values['out-dir'],
|
|
276
|
+
size,
|
|
277
|
+
});
|
|
278
|
+
if (values.json) {
|
|
279
|
+
console.log(JSON.stringify(record, null, 2));
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
console.log(`File: ${record.file}`);
|
|
283
|
+
if (record.sidecar)
|
|
284
|
+
console.log(`Sidecar: ${record.sidecar}`);
|
|
285
|
+
console.log(`Size: ${record.width}x${record.height} (${record.size})`);
|
|
286
|
+
console.log(`Credit: ${record.attribution}`);
|
|
287
|
+
console.log(`Page: ${record.photo_page}`);
|
|
288
|
+
}
|
|
289
|
+
function printSearch(result) {
|
|
290
|
+
console.log(`${result.total} results for "${result.query}" (page ${result.page}/${result.totalPages})`);
|
|
291
|
+
result.results.forEach((photo, index) => {
|
|
292
|
+
console.log(`\n${index + 1}. ${photo.id} ${photo.width}x${photo.height}`);
|
|
293
|
+
console.log(` ${photo.description || '(no description)'}`);
|
|
294
|
+
console.log(` ${photo.attribution}`);
|
|
295
|
+
console.log(` ${photo.photo_page}`);
|
|
296
|
+
});
|
|
297
|
+
if (result.downloads.length) {
|
|
298
|
+
console.log('\nDownloaded:');
|
|
299
|
+
result.downloads.forEach((item) => {
|
|
300
|
+
console.log(`- ${item.file} (${item.attribution})`);
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
console.log(`\nRate: ${result.rate.remaining ?? '?'}/${result.rate.limit ?? '?'} remaining`);
|
|
304
|
+
}
|
|
305
|
+
function intOption(value, flag, fallback, min, max) {
|
|
306
|
+
if (value === undefined)
|
|
307
|
+
return fallback;
|
|
308
|
+
const parsed = Number(value);
|
|
309
|
+
if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
|
|
310
|
+
throw new Error(`${flag} must be an integer between ${min} and ${max}.`);
|
|
311
|
+
}
|
|
312
|
+
return parsed;
|
|
313
|
+
}
|
|
314
|
+
function sizeOption(value) {
|
|
315
|
+
const size = (value ?? 'full');
|
|
316
|
+
if (!photos_1.SIZES.includes(size)) {
|
|
317
|
+
throw new Error(`--size must be one of: ${photos_1.SIZES.join(', ')}.`);
|
|
318
|
+
}
|
|
319
|
+
return size;
|
|
320
|
+
}
|
|
321
|
+
function version() {
|
|
322
|
+
try {
|
|
323
|
+
const pkg = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(__dirname, '..', 'package.json'), 'utf8'));
|
|
324
|
+
return pkg.version ?? '0.0.0';
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
return '0.0.0';
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
if (require.main === module) {
|
|
331
|
+
main().catch((error) => {
|
|
332
|
+
console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
|
|
333
|
+
process.exit(1);
|
|
334
|
+
});
|
|
335
|
+
}
|
package/dist/photos.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SIZES = void 0;
|
|
4
|
+
exports.withUtm = withUtm;
|
|
5
|
+
exports.compactPhoto = compactPhoto;
|
|
6
|
+
exports.parsePhotoId = parsePhotoId;
|
|
7
|
+
exports.downloadPhoto = downloadPhoto;
|
|
8
|
+
const node_fs_1 = require("node:fs");
|
|
9
|
+
const node_path_1 = require("node:path");
|
|
10
|
+
exports.SIZES = ['raw', 'full', 'regular', 'small', 'thumb'];
|
|
11
|
+
function withUtm(url) {
|
|
12
|
+
if (!url)
|
|
13
|
+
return '';
|
|
14
|
+
const separator = url.includes('?') ? '&' : '?';
|
|
15
|
+
return `${url}${separator}utm_source=unsplash-cli&utm_medium=referral`;
|
|
16
|
+
}
|
|
17
|
+
function compactPhoto(photo, query = '') {
|
|
18
|
+
const user = photo.user ?? {};
|
|
19
|
+
const profile = user.links?.html || (user.username ? `https://unsplash.com/@${user.username}` : '');
|
|
20
|
+
const name = user.name || user.username || 'Unknown';
|
|
21
|
+
return {
|
|
22
|
+
id: photo.id,
|
|
23
|
+
description: photo.description || photo.alt_description || '',
|
|
24
|
+
width: photo.width,
|
|
25
|
+
height: photo.height,
|
|
26
|
+
color: photo.color || '',
|
|
27
|
+
urls: photo.urls,
|
|
28
|
+
photographer: {
|
|
29
|
+
name,
|
|
30
|
+
username: user.username || '',
|
|
31
|
+
profile_url: withUtm(profile),
|
|
32
|
+
},
|
|
33
|
+
photo_page: withUtm(photo.links?.html || ''),
|
|
34
|
+
download_location: photo.links?.download_location || '',
|
|
35
|
+
attribution: `Photo by ${name} on Unsplash (${withUtm(profile)})`,
|
|
36
|
+
...(query ? { query } : {}),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function parsePhotoId(ref) {
|
|
40
|
+
if (/^[A-Za-z0-9_-]{6,}$/.test(ref))
|
|
41
|
+
return ref;
|
|
42
|
+
let parsed;
|
|
43
|
+
try {
|
|
44
|
+
parsed = new URL(ref);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
throw new Error(`Invalid photo id or URL: ${ref}`);
|
|
48
|
+
}
|
|
49
|
+
const parts = parsed.pathname.split('/').filter(Boolean);
|
|
50
|
+
let last = parts.pop() ?? '';
|
|
51
|
+
if (last === 'download')
|
|
52
|
+
last = parts.pop() ?? '';
|
|
53
|
+
const id = last.includes('-') ? last.slice(last.lastIndexOf('-') + 1) : last;
|
|
54
|
+
if (!id)
|
|
55
|
+
throw new Error(`Could not parse photo id from URL: ${ref}`);
|
|
56
|
+
return id;
|
|
57
|
+
}
|
|
58
|
+
async function downloadPhoto(api, ref, options) {
|
|
59
|
+
const id = parsePhotoId(ref);
|
|
60
|
+
const photo = await api.photo(id);
|
|
61
|
+
const meta = compactPhoto(photo);
|
|
62
|
+
const source = photo.urls[options.size] ?? photo.urls.full ?? photo.urls.regular;
|
|
63
|
+
if (!source)
|
|
64
|
+
throw new Error(`Photo ${id} has no image URL for size ${options.size}.`);
|
|
65
|
+
const image = await api.fetchImage(source);
|
|
66
|
+
const extension = extensionFor(image.contentType);
|
|
67
|
+
const file = options.out ?? (0, node_path_1.join)(options.outDir ?? '.', `${id}${extension}`);
|
|
68
|
+
(0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(file), { recursive: true });
|
|
69
|
+
(0, node_fs_1.writeFileSync)(file, image.buffer);
|
|
70
|
+
const shouldTrack = options.track !== false && Boolean(meta.download_location);
|
|
71
|
+
const tracked = shouldTrack ? await api.track(meta.download_location) : false;
|
|
72
|
+
if (shouldTrack && !tracked) {
|
|
73
|
+
console.error('warning: Unsplash download tracking failed; the image was saved.');
|
|
74
|
+
}
|
|
75
|
+
const record = {
|
|
76
|
+
provider: 'unsplash',
|
|
77
|
+
...meta,
|
|
78
|
+
size: options.size,
|
|
79
|
+
file,
|
|
80
|
+
bytes: image.buffer.length,
|
|
81
|
+
download_tracked: tracked,
|
|
82
|
+
downloaded_at: new Date().toISOString(),
|
|
83
|
+
license: 'https://unsplash.com/license',
|
|
84
|
+
sidecar: null,
|
|
85
|
+
};
|
|
86
|
+
if (options.sidecar !== false) {
|
|
87
|
+
const sidecar = (0, node_path_1.join)((0, node_path_1.dirname)(file), `${(0, node_path_1.basename)(file, (0, node_path_1.extname)(file))}.json`);
|
|
88
|
+
(0, node_fs_1.writeFileSync)(sidecar, `${JSON.stringify(record, null, 2)}\n`);
|
|
89
|
+
record.sidecar = sidecar;
|
|
90
|
+
}
|
|
91
|
+
return record;
|
|
92
|
+
}
|
|
93
|
+
function extensionFor(contentType) {
|
|
94
|
+
if (contentType.includes('png'))
|
|
95
|
+
return '.png';
|
|
96
|
+
if (contentType.includes('webp'))
|
|
97
|
+
return '.webp';
|
|
98
|
+
if (contentType.includes('gif'))
|
|
99
|
+
return '.gif';
|
|
100
|
+
return '.jpg';
|
|
101
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,51 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@orangemust/unsplash-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Search and download Unsplash photos from the command line. API key stored in macOS Keychain, never in .env.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"unsplash",
|
|
7
|
+
"photos",
|
|
8
|
+
"images",
|
|
9
|
+
"stock-photos",
|
|
10
|
+
"cli",
|
|
11
|
+
"agent",
|
|
12
|
+
"automation"
|
|
13
|
+
],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"author": "mack-peng",
|
|
16
|
+
"homepage": "https://github.com/mack-peng/unsplash-cli#readme",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/mack-peng/unsplash-cli.git"
|
|
20
|
+
},
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/mack-peng/unsplash-cli/issues"
|
|
23
|
+
},
|
|
24
|
+
"main": "dist/index.js",
|
|
25
|
+
"bin": {
|
|
26
|
+
"unsplash": "bin/unsplash.js",
|
|
27
|
+
"unsplash-cli": "bin/unsplash.js"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"bin",
|
|
31
|
+
"dist",
|
|
32
|
+
"README.md",
|
|
33
|
+
"LICENSE",
|
|
34
|
+
"AGENTS.md"
|
|
35
|
+
],
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=20"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "tsc",
|
|
41
|
+
"typecheck": "tsc --noEmit",
|
|
42
|
+
"test": "tsx --test tests/*.test.ts",
|
|
43
|
+
"dev": "tsx src/index.ts",
|
|
44
|
+
"prepublishOnly": "npm run build"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@types/node": "^22.0.0",
|
|
48
|
+
"tsx": "^4.19.0",
|
|
49
|
+
"typescript": "^5.6.0"
|
|
50
|
+
}
|
|
51
|
+
}
|