shipmail-cli 0.6.0 → 0.6.2
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 +22 -2
- package/dist/cli.d.ts +26 -0
- package/dist/cli.js +859 -32
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -8,7 +8,22 @@ npx -y shipmail-cli --help
|
|
|
8
8
|
|
|
9
9
|
## Authentication
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
Sign in once through the browser, with no API key to copy:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
shipmail login
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The CLI opens Shipmail, you choose the organizations it may reach, and it saves the login in the
|
|
18
|
+
macOS Keychain, in the Secret Service through `secret-tool` on Linux, or otherwise in
|
|
19
|
+
`~/.config/shipmail/credentials.json`, readable only by you. The login refreshes itself.
|
|
20
|
+
`shipmail login --no-browser` prints the URL without opening a browser. If the login can reach
|
|
21
|
+
several organizations, pick one with `--organization org_...` or `SHIPMAIL_ORGANIZATION`, or save a
|
|
22
|
+
default with `shipmail login --organization org_...`. `shipmail logout` revokes the login and
|
|
23
|
+
deletes it.
|
|
24
|
+
|
|
25
|
+
An API key still works and takes precedence over the login. Create one in the Shipmail dashboard
|
|
26
|
+
and export it:
|
|
12
27
|
|
|
13
28
|
```bash
|
|
14
29
|
export SHIPMAIL_API_KEY="sm_live_..."
|
|
@@ -31,7 +46,7 @@ shipmail domains verify dom_123
|
|
|
31
46
|
shipmail mailboxes create --domain-id dom_123 --address hello --password-file ./mailbox-password.txt --display-name "Hello"
|
|
32
47
|
shipmail mailboxes suspend mbx_123
|
|
33
48
|
shipmail mailboxes resume mbx_123
|
|
34
|
-
shipmail mailboxes forwarding-add mbx_123 --destination owner@example.net
|
|
49
|
+
shipmail mailboxes forwarding-add mbx_123 --destination owner@example.net --recipient support@example.com
|
|
35
50
|
shipmail mailboxes forwarding mbx_123
|
|
36
51
|
shipmail mailboxes forwarding-remove mbx_123 fwd_123
|
|
37
52
|
shipmail mailboxes rules mbx_123
|
|
@@ -152,6 +167,7 @@ Use repeatable `--header "Name: value"` flags for request headers. Use `--output
|
|
|
152
167
|
- `api <METHOD> <PATH>`
|
|
153
168
|
- `domains list|search|create|get|verify|delete`
|
|
154
169
|
- `mailboxes list|create|get|suspend|resume|forwarding|forwarding-add|forwarding-remove|app-passwords|folders|identities|stage-attachment|prepare-attachment-upload`
|
|
170
|
+
- `members list|get`
|
|
155
171
|
- `inbox list|message|threads|thread|thread-state|draft|send-draft|reply-message|reply-thread|update|move`
|
|
156
172
|
- `reply-scans create|status|results`
|
|
157
173
|
- `messages send|list|analytics|get|reply` (test keys also support `--sandbox-outcome`)
|
|
@@ -167,6 +183,10 @@ Use repeatable `--header "Name: value"` flags for request headers. Use `--output
|
|
|
167
183
|
- `newsletters create|update|preview|preflight|test-send|schedule|cancel|resume`
|
|
168
184
|
- `newsletter-assets list|upload|register-url`
|
|
169
185
|
|
|
186
|
+
Member output uses the roles `owner`, `admin`, and `member`. Its `mailbox_access.scope` is `all` or
|
|
187
|
+
`selected`; `mailbox_ids` is empty for `all`, while selected IDs may be limited to mailboxes the
|
|
188
|
+
API key can reach.
|
|
189
|
+
|
|
170
190
|
Configure a publisher-owned Atom self URL and the number of recent issues:
|
|
171
191
|
|
|
172
192
|
```bash
|
package/dist/cli.d.ts
CHANGED
|
@@ -1,4 +1,27 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
declare const storedLoginSchema: z.ZodObject<{
|
|
5
|
+
baseUrl: z.ZodString;
|
|
6
|
+
clientId: z.ZodString;
|
|
7
|
+
accessToken: z.ZodString;
|
|
8
|
+
refreshToken: z.ZodOptional<z.ZodString>;
|
|
9
|
+
expiresAt: z.ZodNumber;
|
|
10
|
+
tokenEndpoint: z.ZodString;
|
|
11
|
+
revocationEndpoint: z.ZodOptional<z.ZodString>;
|
|
12
|
+
organizationId: z.ZodOptional<z.ZodString>;
|
|
13
|
+
}, z.core.$strip>;
|
|
14
|
+
type StoredLogin = z.infer<typeof storedLoginSchema>;
|
|
15
|
+
type CredentialBackend = {
|
|
16
|
+
readonly kind: "keychain" | "secret-tool" | "file";
|
|
17
|
+
read(baseUrl: string): Promise<StoredLogin | null>;
|
|
18
|
+
write(login: StoredLogin): Promise<void>;
|
|
19
|
+
delete(baseUrl: string): Promise<void>;
|
|
20
|
+
};
|
|
21
|
+
type CredentialStore = CredentialBackend & {
|
|
22
|
+
withLock<T>(fn: () => Promise<T>): Promise<T>;
|
|
23
|
+
};
|
|
24
|
+
|
|
2
25
|
type Output = {
|
|
3
26
|
write(chunk: string): void;
|
|
4
27
|
};
|
|
@@ -8,6 +31,9 @@ type RunCliOptions = {
|
|
|
8
31
|
readonly stdout?: Output | undefined;
|
|
9
32
|
readonly stderr?: Output | undefined;
|
|
10
33
|
readonly fetch?: typeof fetch | undefined;
|
|
34
|
+
readonly credentialStore?: CredentialStore | undefined;
|
|
35
|
+
readonly openBrowser?: ((url: string) => void) | undefined;
|
|
36
|
+
readonly loginTimeoutMs?: number | undefined;
|
|
11
37
|
};
|
|
12
38
|
declare function runCli(argv: readonly string[], options?: RunCliOptions): Promise<number>;
|
|
13
39
|
declare function isMainModule(moduleUrl?: string, argvPath?: string | undefined): boolean;
|
package/dist/cli.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
-
import { createHash } from "crypto";
|
|
4
|
+
import { createHash as createHash2 } from "crypto";
|
|
5
5
|
import { realpathSync } from "fs";
|
|
6
|
-
import { readFile, writeFile } from "fs/promises";
|
|
6
|
+
import { readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
|
|
7
7
|
import { env as processEnv, exit, stderr, stdout } from "process";
|
|
8
8
|
import { fileURLToPath } from "url";
|
|
9
9
|
import {
|
|
@@ -14,8 +14,642 @@ import {
|
|
|
14
14
|
WEBHOOK_EVENT_TYPES
|
|
15
15
|
} from "shipmail";
|
|
16
16
|
|
|
17
|
+
// src/credentials.ts
|
|
18
|
+
import { spawn } from "child_process";
|
|
19
|
+
import { randomBytes } from "crypto";
|
|
20
|
+
import { constants } from "fs";
|
|
21
|
+
import { access, chmod, link, mkdir, readFile, rename, unlink, writeFile } from "fs/promises";
|
|
22
|
+
import { homedir } from "os";
|
|
23
|
+
import { delimiter, dirname, join } from "path";
|
|
24
|
+
import { z } from "zod";
|
|
25
|
+
var KEYCHAIN_SERVICE = "shipmail-cli";
|
|
26
|
+
var storedLoginSchema = z.object({
|
|
27
|
+
baseUrl: z.string(),
|
|
28
|
+
clientId: z.string(),
|
|
29
|
+
accessToken: z.string(),
|
|
30
|
+
refreshToken: z.string().optional(),
|
|
31
|
+
expiresAt: z.number(),
|
|
32
|
+
tokenEndpoint: z.string(),
|
|
33
|
+
revocationEndpoint: z.string().optional(),
|
|
34
|
+
organizationId: z.string().optional()
|
|
35
|
+
});
|
|
36
|
+
var fileContentsSchema = z.object({ logins: z.record(z.string(), storedLoginSchema) });
|
|
37
|
+
function parseLogin(raw) {
|
|
38
|
+
try {
|
|
39
|
+
const parsed = storedLoginSchema.safeParse(JSON.parse(raw));
|
|
40
|
+
return parsed.success ? parsed.data : null;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
var runCommand = (command, args, input) => new Promise((resolve, reject) => {
|
|
46
|
+
const child = spawn(command, [...args], { stdio: ["pipe", "pipe", "ignore"] });
|
|
47
|
+
let stdout2 = "";
|
|
48
|
+
child.stdout.setEncoding("utf8");
|
|
49
|
+
child.stdout.on("data", (chunk) => {
|
|
50
|
+
stdout2 += chunk;
|
|
51
|
+
});
|
|
52
|
+
child.on("error", reject);
|
|
53
|
+
child.on("close", (code) => resolve({ code: code ?? 1, stdout: stdout2 }));
|
|
54
|
+
child.stdin.end(input ?? "");
|
|
55
|
+
});
|
|
56
|
+
var SECURITY_MAX_LINE = 4e3;
|
|
57
|
+
var KEYCHAIN_ITEM_NOT_FOUND = 44;
|
|
58
|
+
function securityQuote(value) {
|
|
59
|
+
if (/[\r\n]/.test(value)) throw new Error("A Keychain value cannot contain a line break.");
|
|
60
|
+
return `"${value.replace(/["\\]/g, (character) => `\\${character}`)}"`;
|
|
61
|
+
}
|
|
62
|
+
function keychainStore(run) {
|
|
63
|
+
return {
|
|
64
|
+
kind: "keychain",
|
|
65
|
+
async read(baseUrl2) {
|
|
66
|
+
const result = await run("security", [
|
|
67
|
+
"find-generic-password",
|
|
68
|
+
"-s",
|
|
69
|
+
KEYCHAIN_SERVICE,
|
|
70
|
+
"-a",
|
|
71
|
+
baseUrl2,
|
|
72
|
+
"-w"
|
|
73
|
+
]);
|
|
74
|
+
return result.code === 0 ? parseLogin(result.stdout.trim()) : null;
|
|
75
|
+
},
|
|
76
|
+
// The secret goes to security -i on stdin, never into argv, where ps could read it.
|
|
77
|
+
async write(login2) {
|
|
78
|
+
const command = [
|
|
79
|
+
"add-generic-password",
|
|
80
|
+
"-U",
|
|
81
|
+
"-s",
|
|
82
|
+
KEYCHAIN_SERVICE,
|
|
83
|
+
"-a",
|
|
84
|
+
login2.baseUrl,
|
|
85
|
+
"-l",
|
|
86
|
+
"Shipmail CLI",
|
|
87
|
+
"-w",
|
|
88
|
+
JSON.stringify(login2)
|
|
89
|
+
].map(securityQuote).join(" ");
|
|
90
|
+
if (command.length > SECURITY_MAX_LINE) {
|
|
91
|
+
throw new Error("The login is too large for the macOS Keychain.");
|
|
92
|
+
}
|
|
93
|
+
const result = await run("security", ["-i"], `${command}
|
|
94
|
+
`);
|
|
95
|
+
if (result.code !== 0) throw new Error("Could not save the login to the macOS Keychain.");
|
|
96
|
+
},
|
|
97
|
+
async delete(baseUrl2) {
|
|
98
|
+
const result = await run("security", [
|
|
99
|
+
"delete-generic-password",
|
|
100
|
+
"-s",
|
|
101
|
+
KEYCHAIN_SERVICE,
|
|
102
|
+
"-a",
|
|
103
|
+
baseUrl2
|
|
104
|
+
]);
|
|
105
|
+
if (result.code !== 0 && result.code !== KEYCHAIN_ITEM_NOT_FOUND) {
|
|
106
|
+
throw new Error("Could not delete the login from the macOS Keychain.");
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
function secretToolStore(run) {
|
|
112
|
+
const attributes = (baseUrl2) => ["service", KEYCHAIN_SERVICE, "account", baseUrl2];
|
|
113
|
+
return {
|
|
114
|
+
kind: "secret-tool",
|
|
115
|
+
async read(baseUrl2) {
|
|
116
|
+
const result = await run("secret-tool", ["lookup", ...attributes(baseUrl2)]);
|
|
117
|
+
return result.code === 0 ? parseLogin(result.stdout.trim()) : null;
|
|
118
|
+
},
|
|
119
|
+
async write(login2) {
|
|
120
|
+
const result = await run(
|
|
121
|
+
"secret-tool",
|
|
122
|
+
["store", "--label=Shipmail CLI", ...attributes(login2.baseUrl)],
|
|
123
|
+
JSON.stringify(login2)
|
|
124
|
+
);
|
|
125
|
+
if (result.code !== 0) throw new Error("Could not save the login with secret-tool.");
|
|
126
|
+
},
|
|
127
|
+
// secret-tool clear exits 0 when nothing matched as well, so any other exit is a failure.
|
|
128
|
+
async delete(baseUrl2) {
|
|
129
|
+
const result = await run("secret-tool", ["clear", ...attributes(baseUrl2)]);
|
|
130
|
+
if (result.code !== 0) throw new Error("Could not delete the login with secret-tool.");
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
function credentialsFilePath(env) {
|
|
135
|
+
const configHome = env["XDG_CONFIG_HOME"] || join(env["HOME"] || homedir(), ".config");
|
|
136
|
+
return join(configHome, "shipmail", "credentials.json");
|
|
137
|
+
}
|
|
138
|
+
function fileStore(path) {
|
|
139
|
+
const directory = dirname(path);
|
|
140
|
+
async function readAll() {
|
|
141
|
+
let raw;
|
|
142
|
+
try {
|
|
143
|
+
raw = await readFile(path, "utf8");
|
|
144
|
+
} catch {
|
|
145
|
+
return {};
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
const parsed = fileContentsSchema.safeParse(JSON.parse(raw));
|
|
149
|
+
return parsed.success ? parsed.data.logins : {};
|
|
150
|
+
} catch {
|
|
151
|
+
return {};
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
async function writeAll(logins) {
|
|
155
|
+
await mkdir(directory, { recursive: true, mode: 448 });
|
|
156
|
+
await chmod(directory, 448);
|
|
157
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
158
|
+
await writeFile(temporary, `${JSON.stringify({ logins }, null, 2)}
|
|
159
|
+
`, { mode: 384 });
|
|
160
|
+
await chmod(temporary, 384);
|
|
161
|
+
await rename(temporary, path);
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
kind: "file",
|
|
165
|
+
async read(baseUrl2) {
|
|
166
|
+
return (await readAll())[baseUrl2] ?? null;
|
|
167
|
+
},
|
|
168
|
+
async write(login2) {
|
|
169
|
+
await writeAll({ ...await readAll(), [login2.baseUrl]: login2 });
|
|
170
|
+
},
|
|
171
|
+
async delete(baseUrl2) {
|
|
172
|
+
const logins = await readAll();
|
|
173
|
+
if (!(baseUrl2 in logins)) return;
|
|
174
|
+
const rest = { ...logins };
|
|
175
|
+
delete rest[baseUrl2];
|
|
176
|
+
await writeAll(rest);
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
var LOCK_WAIT_MS = 6e4;
|
|
181
|
+
var LOCK_RETRY_MS = 50;
|
|
182
|
+
function errorCode(error) {
|
|
183
|
+
return error instanceof Error && "code" in error ? error.code : void 0;
|
|
184
|
+
}
|
|
185
|
+
function processAlive(pid) {
|
|
186
|
+
try {
|
|
187
|
+
process.kill(pid, 0);
|
|
188
|
+
return true;
|
|
189
|
+
} catch (error) {
|
|
190
|
+
return errorCode(error) === "EPERM";
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
async function readLock(path) {
|
|
194
|
+
return readFile(path, "utf8").catch(() => null);
|
|
195
|
+
}
|
|
196
|
+
function lockHolder(content) {
|
|
197
|
+
const match = /^(\d+) [0-9a-f]+$/.exec(content);
|
|
198
|
+
return match?.[1] ? Number(match[1]) : null;
|
|
199
|
+
}
|
|
200
|
+
async function tryCreateLock(path, owner) {
|
|
201
|
+
const staged = `${path}.${randomBytes(8).toString("hex")}.tmp`;
|
|
202
|
+
await writeFile(staged, owner, { mode: 384, flag: "wx" });
|
|
203
|
+
try {
|
|
204
|
+
await link(staged, path);
|
|
205
|
+
return true;
|
|
206
|
+
} catch (error) {
|
|
207
|
+
if (errorCode(error) === "EEXIST") return false;
|
|
208
|
+
throw error;
|
|
209
|
+
} finally {
|
|
210
|
+
await unlink(staged).catch(() => void 0);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
function abandoned(content) {
|
|
214
|
+
const holder = lockHolder(content);
|
|
215
|
+
return holder === null || !processAlive(holder);
|
|
216
|
+
}
|
|
217
|
+
async function breakLock(path, observed, owner) {
|
|
218
|
+
const breaker = `${path}.break`;
|
|
219
|
+
if (!await tryCreateLock(breaker, owner)) {
|
|
220
|
+
const content = await readLock(breaker);
|
|
221
|
+
if (content !== null && abandoned(content) && await readLock(breaker) === content) {
|
|
222
|
+
await unlink(breaker).catch(() => void 0);
|
|
223
|
+
}
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
if (await readLock(path) === observed) await unlink(path).catch(() => void 0);
|
|
228
|
+
} finally {
|
|
229
|
+
if (await readLock(breaker) === owner) await unlink(breaker).catch(() => void 0);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
async function withFileLock(path, fn, waitMs = LOCK_WAIT_MS) {
|
|
233
|
+
await mkdir(dirname(path), { recursive: true, mode: 448 });
|
|
234
|
+
const owner = `${process.pid} ${randomBytes(16).toString("hex")}`;
|
|
235
|
+
const deadline = Date.now() + waitMs;
|
|
236
|
+
while (!await tryCreateLock(path, owner)) {
|
|
237
|
+
if (Date.now() > deadline) {
|
|
238
|
+
throw new Error(
|
|
239
|
+
`Another shipmail command is using the saved login. If none is running, delete ${path}.`
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
const observed = await readLock(path);
|
|
243
|
+
if (observed !== null && abandoned(observed)) await breakLock(path, observed, owner);
|
|
244
|
+
await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_MS));
|
|
245
|
+
}
|
|
246
|
+
try {
|
|
247
|
+
return await fn();
|
|
248
|
+
} finally {
|
|
249
|
+
if (await readLock(path) === owner) await unlink(path).catch(() => void 0);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
async function onPath(command, env) {
|
|
253
|
+
for (const directory of (env["PATH"] ?? "").split(delimiter)) {
|
|
254
|
+
if (!directory) continue;
|
|
255
|
+
try {
|
|
256
|
+
await access(join(directory, command), constants.X_OK);
|
|
257
|
+
return true;
|
|
258
|
+
} catch {
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
async function defaultCredentialStore(options) {
|
|
264
|
+
const path = credentialsFilePath(options.env);
|
|
265
|
+
const file = fileStore(path);
|
|
266
|
+
const lockPath = join(dirname(path), "credentials.lock");
|
|
267
|
+
const withLock = (fn) => withFileLock(lockPath, fn);
|
|
268
|
+
const fileNotice = () => options.notice(`Saved the login in ${path}, readable only by you. No system keychain found.`);
|
|
269
|
+
let native = null;
|
|
270
|
+
if (options.platform === "darwin") native = keychainStore(options.run);
|
|
271
|
+
else if (options.platform === "linux" && await onPath("secret-tool", options.env)) {
|
|
272
|
+
native = secretToolStore(options.run);
|
|
273
|
+
}
|
|
274
|
+
if (!native) {
|
|
275
|
+
return {
|
|
276
|
+
kind: "file",
|
|
277
|
+
read: file.read,
|
|
278
|
+
async write(login2) {
|
|
279
|
+
await file.write(login2);
|
|
280
|
+
fileNotice();
|
|
281
|
+
},
|
|
282
|
+
delete: file.delete,
|
|
283
|
+
withLock
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
const store = native;
|
|
287
|
+
return {
|
|
288
|
+
kind: store.kind,
|
|
289
|
+
async read(baseUrl2) {
|
|
290
|
+
return await file.read(baseUrl2) ?? await store.read(baseUrl2).catch(() => null);
|
|
291
|
+
},
|
|
292
|
+
async write(login2) {
|
|
293
|
+
try {
|
|
294
|
+
await store.write(login2);
|
|
295
|
+
} catch {
|
|
296
|
+
await file.write(login2);
|
|
297
|
+
fileNotice();
|
|
298
|
+
await store.delete(login2.baseUrl).catch(() => void 0);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
await file.delete(login2.baseUrl);
|
|
302
|
+
},
|
|
303
|
+
async delete(baseUrl2) {
|
|
304
|
+
const nativeError = await store.delete(baseUrl2).then(
|
|
305
|
+
() => null,
|
|
306
|
+
(error) => error instanceof Error ? error : new Error(String(error))
|
|
307
|
+
);
|
|
308
|
+
if (nativeError) throw nativeError;
|
|
309
|
+
await file.delete(baseUrl2);
|
|
310
|
+
},
|
|
311
|
+
withLock
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// src/oauth.ts
|
|
316
|
+
import { spawn as spawn2 } from "child_process";
|
|
317
|
+
import { createHash, randomBytes as randomBytes2 } from "crypto";
|
|
318
|
+
import { createServer } from "http";
|
|
319
|
+
import { z as z2 } from "zod";
|
|
320
|
+
var LOGIN_TIMEOUT_MS = 5 * 6e4;
|
|
321
|
+
var REQUEST_TIMEOUT_MS = 3e4;
|
|
322
|
+
var SCOPE = "openid profile email offline_access";
|
|
323
|
+
var REGISTERED_REDIRECT_URI = "http://127.0.0.1/callback";
|
|
324
|
+
var protectedResourceSchema = z2.object({
|
|
325
|
+
resource: z2.string(),
|
|
326
|
+
authorization_servers: z2.array(z2.string()).min(1)
|
|
327
|
+
});
|
|
328
|
+
var authorizationServerSchema = z2.object({
|
|
329
|
+
issuer: z2.string(),
|
|
330
|
+
authorization_endpoint: z2.string(),
|
|
331
|
+
token_endpoint: z2.string(),
|
|
332
|
+
registration_endpoint: z2.string().optional(),
|
|
333
|
+
revocation_endpoint: z2.string().optional(),
|
|
334
|
+
code_challenge_methods_supported: z2.array(z2.string()).optional()
|
|
335
|
+
});
|
|
336
|
+
var registrationSchema = z2.object({ client_id: z2.string().min(1) });
|
|
337
|
+
var tokenSchema = z2.object({
|
|
338
|
+
access_token: z2.string().min(1),
|
|
339
|
+
token_type: z2.string(),
|
|
340
|
+
expires_in: z2.number().optional(),
|
|
341
|
+
refresh_token: z2.string().optional()
|
|
342
|
+
});
|
|
343
|
+
var oauthErrorSchema = z2.object({
|
|
344
|
+
error: z2.string(),
|
|
345
|
+
error_description: z2.string().optional()
|
|
346
|
+
});
|
|
347
|
+
var OAuthError = class extends Error {
|
|
348
|
+
constructor(code, message) {
|
|
349
|
+
super(message);
|
|
350
|
+
this.code = code;
|
|
351
|
+
this.name = "OAuthError";
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
function trimSlash(value) {
|
|
355
|
+
return value.replace(/\/+$/, "");
|
|
356
|
+
}
|
|
357
|
+
async function getJson(fetchFn, url) {
|
|
358
|
+
const response = await fetchFn(url, {
|
|
359
|
+
headers: { Accept: "application/json" },
|
|
360
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
361
|
+
});
|
|
362
|
+
if (!response.ok)
|
|
363
|
+
throw new Error(`Sign-in discovery failed: ${url} returned ${response.status}.`);
|
|
364
|
+
return response.json();
|
|
365
|
+
}
|
|
366
|
+
function parseOrThrow(schema, value, what) {
|
|
367
|
+
const parsed = schema.safeParse(value);
|
|
368
|
+
if (!parsed.success) throw new Error(`The server sent an invalid ${what}.`);
|
|
369
|
+
return parsed.data;
|
|
370
|
+
}
|
|
371
|
+
function sameOrigin(url, origin, what) {
|
|
372
|
+
let parsed;
|
|
373
|
+
try {
|
|
374
|
+
parsed = new URL(url);
|
|
375
|
+
} catch {
|
|
376
|
+
throw new Error(`The server sent an invalid ${what}.`);
|
|
377
|
+
}
|
|
378
|
+
if (parsed.origin !== origin) throw new Error(`The ${what} is not on ${origin}.`);
|
|
379
|
+
return url;
|
|
380
|
+
}
|
|
381
|
+
async function discoverOAuthServer(apiBaseUrl, fetchFn) {
|
|
382
|
+
const origin = new URL(apiBaseUrl).origin;
|
|
383
|
+
const resource = `${origin}/api/v1`;
|
|
384
|
+
const protectedResource = parseOrThrow(
|
|
385
|
+
protectedResourceSchema,
|
|
386
|
+
await getJson(fetchFn, `${origin}/.well-known/oauth-protected-resource/api/v1`),
|
|
387
|
+
"protected resource document"
|
|
388
|
+
);
|
|
389
|
+
if (trimSlash(protectedResource.resource) !== resource) {
|
|
390
|
+
throw new Error(`The protected resource document is not for ${resource}.`);
|
|
391
|
+
}
|
|
392
|
+
const [issuer = ""] = protectedResource.authorization_servers;
|
|
393
|
+
const issuerUrl = new URL(sameOrigin(issuer, origin, "authorization server"));
|
|
394
|
+
const issuerPath = trimSlash(issuerUrl.pathname);
|
|
395
|
+
const metadata = parseOrThrow(
|
|
396
|
+
authorizationServerSchema,
|
|
397
|
+
await getJson(fetchFn, `${origin}/.well-known/oauth-authorization-server${issuerPath}`),
|
|
398
|
+
"authorization server document"
|
|
399
|
+
);
|
|
400
|
+
if (trimSlash(metadata.issuer) !== trimSlash(issuer)) {
|
|
401
|
+
throw new Error("The authorization server document names a different issuer.");
|
|
402
|
+
}
|
|
403
|
+
if (metadata.code_challenge_methods_supported && !metadata.code_challenge_methods_supported.includes("S256")) {
|
|
404
|
+
throw new Error("The authorization server does not support PKCE with S256.");
|
|
405
|
+
}
|
|
406
|
+
return {
|
|
407
|
+
resource,
|
|
408
|
+
authorizationEndpoint: sameOrigin(
|
|
409
|
+
metadata.authorization_endpoint,
|
|
410
|
+
origin,
|
|
411
|
+
"authorization endpoint"
|
|
412
|
+
),
|
|
413
|
+
tokenEndpoint: sameOrigin(metadata.token_endpoint, origin, "token endpoint"),
|
|
414
|
+
registrationEndpoint: metadata.registration_endpoint === void 0 ? void 0 : sameOrigin(metadata.registration_endpoint, origin, "registration endpoint"),
|
|
415
|
+
revocationEndpoint: metadata.revocation_endpoint === void 0 ? void 0 : sameOrigin(metadata.revocation_endpoint, origin, "revocation endpoint")
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
async function oauthFailure(response, fallback) {
|
|
419
|
+
const body = await response.json().catch(() => null);
|
|
420
|
+
const parsed = oauthErrorSchema.safeParse(body);
|
|
421
|
+
if (!parsed.success)
|
|
422
|
+
return new OAuthError("server_error", `${fallback} (HTTP ${response.status}).`);
|
|
423
|
+
return new OAuthError(
|
|
424
|
+
parsed.data.error,
|
|
425
|
+
parsed.data.error_description ? `${fallback}: ${parsed.data.error_description}` : `${fallback}: ${parsed.data.error}`
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
async function registerClient(server, fetchFn) {
|
|
429
|
+
if (!server.registrationEndpoint) {
|
|
430
|
+
throw new Error("The authorization server does not allow client registration.");
|
|
431
|
+
}
|
|
432
|
+
const response = await fetchFn(server.registrationEndpoint, {
|
|
433
|
+
method: "POST",
|
|
434
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
435
|
+
body: JSON.stringify({
|
|
436
|
+
client_name: "Shipmail CLI",
|
|
437
|
+
application_type: "native",
|
|
438
|
+
redirect_uris: [REGISTERED_REDIRECT_URI],
|
|
439
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
440
|
+
response_types: ["code"],
|
|
441
|
+
token_endpoint_auth_method: "none"
|
|
442
|
+
}),
|
|
443
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
444
|
+
});
|
|
445
|
+
if (!response.ok) throw await oauthFailure(response, "Client registration failed");
|
|
446
|
+
return parseOrThrow(registrationSchema, await response.json(), "client registration").client_id;
|
|
447
|
+
}
|
|
448
|
+
async function tokenRequest(tokenEndpoint, fetchFn, params, failure) {
|
|
449
|
+
const response = await fetchFn(tokenEndpoint, {
|
|
450
|
+
method: "POST",
|
|
451
|
+
headers: {
|
|
452
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
453
|
+
Accept: "application/json"
|
|
454
|
+
},
|
|
455
|
+
body: new URLSearchParams(params).toString(),
|
|
456
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
457
|
+
});
|
|
458
|
+
if (!response.ok) throw await oauthFailure(response, failure);
|
|
459
|
+
const tokens = parseOrThrow(tokenSchema, await response.json(), "token response");
|
|
460
|
+
if (tokens.token_type.toLowerCase() !== "bearer") {
|
|
461
|
+
throw new Error("The server issued an unsupported token type.");
|
|
462
|
+
}
|
|
463
|
+
return tokens;
|
|
464
|
+
}
|
|
465
|
+
function storedTokens(tokens, previousRefreshToken, now) {
|
|
466
|
+
const refreshToken = tokens.refresh_token ?? previousRefreshToken;
|
|
467
|
+
return {
|
|
468
|
+
accessToken: tokens.access_token,
|
|
469
|
+
...refreshToken ? { refreshToken } : {},
|
|
470
|
+
expiresAt: now + (tokens.expires_in ?? 3600) * 1e3
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
async function refreshLogin(login2, fetchFn, now = Date.now()) {
|
|
474
|
+
if (!login2.refreshToken) {
|
|
475
|
+
throw new OAuthError("invalid_grant", "The login has expired and cannot be refreshed");
|
|
476
|
+
}
|
|
477
|
+
const tokens = await tokenRequest(
|
|
478
|
+
login2.tokenEndpoint,
|
|
479
|
+
fetchFn,
|
|
480
|
+
{
|
|
481
|
+
grant_type: "refresh_token",
|
|
482
|
+
refresh_token: login2.refreshToken,
|
|
483
|
+
client_id: login2.clientId,
|
|
484
|
+
resource: `${new URL(login2.baseUrl).origin}/api/v1`
|
|
485
|
+
},
|
|
486
|
+
"Token refresh failed"
|
|
487
|
+
);
|
|
488
|
+
return { ...login2, ...storedTokens(tokens, login2.refreshToken, now) };
|
|
489
|
+
}
|
|
490
|
+
async function revokeToken(revocationEndpoint, clientId, token, fetchFn) {
|
|
491
|
+
const response = await fetchFn(revocationEndpoint, {
|
|
492
|
+
method: "POST",
|
|
493
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
494
|
+
body: new URLSearchParams({
|
|
495
|
+
token,
|
|
496
|
+
token_type_hint: "refresh_token",
|
|
497
|
+
client_id: clientId
|
|
498
|
+
}).toString(),
|
|
499
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
500
|
+
});
|
|
501
|
+
if (!response.ok) throw await oauthFailure(response, "Token revocation failed");
|
|
502
|
+
}
|
|
503
|
+
function randomToken(bytes) {
|
|
504
|
+
return randomBytes2(bytes).toString("base64url");
|
|
505
|
+
}
|
|
506
|
+
function codeChallenge(verifier) {
|
|
507
|
+
return createHash("sha256").update(verifier).digest("base64url");
|
|
508
|
+
}
|
|
509
|
+
function callbackPage(message) {
|
|
510
|
+
return `<!doctype html><meta charset="utf-8"><title>Shipmail CLI</title><p>${message}</p>
|
|
511
|
+
`;
|
|
512
|
+
}
|
|
513
|
+
var LOOPBACK_ORIGIN = "http://127.0.0.1";
|
|
514
|
+
function callbackTarget(target) {
|
|
515
|
+
if (!target?.startsWith("/")) return null;
|
|
516
|
+
try {
|
|
517
|
+
const url = new URL(target, LOOPBACK_ORIGIN);
|
|
518
|
+
return url.origin === LOOPBACK_ORIGIN ? url : null;
|
|
519
|
+
} catch {
|
|
520
|
+
return null;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
async function startLoopbackListener(expectedState, timeoutMs) {
|
|
524
|
+
let settle;
|
|
525
|
+
const code = new Promise((resolve, reject) => {
|
|
526
|
+
settle = { resolve, reject };
|
|
527
|
+
});
|
|
528
|
+
const server = createServer((request, response) => {
|
|
529
|
+
const plain = (status, message) => response.writeHead(status, { "Content-Type": "text/plain; charset=utf-8" }).end(`${message}
|
|
530
|
+
`);
|
|
531
|
+
if (request.method !== "GET") {
|
|
532
|
+
plain(405, "Method not allowed");
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
const url = callbackTarget(request.url);
|
|
536
|
+
if (!url || url.pathname !== "/callback") {
|
|
537
|
+
plain(404, "Not found");
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
const reply = (status, message) => response.writeHead(status, { "Content-Type": "text/html; charset=utf-8", Connection: "close" }).end(callbackPage(message));
|
|
541
|
+
if (url.searchParams.get("state") !== expectedState) {
|
|
542
|
+
reply(400, "This sign-in link does not match the CLI. Run shipmail login again.");
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
const error = url.searchParams.get("error");
|
|
546
|
+
if (error) {
|
|
547
|
+
const description = url.searchParams.get("error_description");
|
|
548
|
+
reply(400, "Sign-in was not completed. You can close this tab.");
|
|
549
|
+
settle?.reject(new OAuthError(error, `Sign-in failed: ${description ?? error}`));
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
const authorizationCode = url.searchParams.get("code");
|
|
553
|
+
if (!authorizationCode) {
|
|
554
|
+
reply(400, "The sign-in response had no code. Run shipmail login again.");
|
|
555
|
+
settle?.reject(new Error("The sign-in response had no authorization code."));
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
reply(200, "Signed in to the Shipmail CLI. You can close this tab.");
|
|
559
|
+
settle?.resolve(authorizationCode);
|
|
560
|
+
});
|
|
561
|
+
await new Promise((resolve, reject) => {
|
|
562
|
+
server.once("error", reject);
|
|
563
|
+
server.listen(0, "127.0.0.1", () => resolve());
|
|
564
|
+
});
|
|
565
|
+
const address = server.address();
|
|
566
|
+
if (address === null || typeof address === "string") {
|
|
567
|
+
server.close();
|
|
568
|
+
throw new Error("Could not open a local port for the sign-in callback.");
|
|
569
|
+
}
|
|
570
|
+
const { port } = address;
|
|
571
|
+
const timer = setTimeout(() => {
|
|
572
|
+
settle?.reject(
|
|
573
|
+
new Error("Timed out waiting for the browser sign-in. Run shipmail login again.")
|
|
574
|
+
);
|
|
575
|
+
}, timeoutMs);
|
|
576
|
+
return {
|
|
577
|
+
redirectUri: `http://127.0.0.1:${port}/callback`,
|
|
578
|
+
code,
|
|
579
|
+
close() {
|
|
580
|
+
clearTimeout(timer);
|
|
581
|
+
server.close();
|
|
582
|
+
server.closeAllConnections();
|
|
583
|
+
}
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
function openInBrowser(url) {
|
|
587
|
+
const [command, args, verbatim] = process.platform === "darwin" ? ["open", [url], false] : process.platform === "win32" ? ["cmd", ["/c", "start", '""', `"${url}"`], true] : ["xdg-open", [url], false];
|
|
588
|
+
try {
|
|
589
|
+
const child = spawn2(command, args, {
|
|
590
|
+
detached: true,
|
|
591
|
+
stdio: "ignore",
|
|
592
|
+
windowsVerbatimArguments: verbatim
|
|
593
|
+
});
|
|
594
|
+
child.on("error", () => void 0);
|
|
595
|
+
child.unref();
|
|
596
|
+
} catch {
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
async function login(options) {
|
|
600
|
+
const server = await discoverOAuthServer(options.baseUrl, options.fetch);
|
|
601
|
+
const existing = await options.store.read(options.baseUrl);
|
|
602
|
+
const clientId = existing?.clientId ?? await registerClient(server, options.fetch);
|
|
603
|
+
const verifier = randomToken(32);
|
|
604
|
+
const state = randomToken(16);
|
|
605
|
+
const listener = await startLoopbackListener(state, options.timeoutMs);
|
|
606
|
+
try {
|
|
607
|
+
const authorizeUrl = new URL(server.authorizationEndpoint);
|
|
608
|
+
authorizeUrl.search = new URLSearchParams({
|
|
609
|
+
response_type: "code",
|
|
610
|
+
client_id: clientId,
|
|
611
|
+
redirect_uri: listener.redirectUri,
|
|
612
|
+
scope: SCOPE,
|
|
613
|
+
state,
|
|
614
|
+
code_challenge: codeChallenge(verifier),
|
|
615
|
+
code_challenge_method: "S256",
|
|
616
|
+
resource: server.resource
|
|
617
|
+
}).toString();
|
|
618
|
+
options.print("Open this URL to sign in to Shipmail:");
|
|
619
|
+
options.print(authorizeUrl.toString());
|
|
620
|
+
options.openBrowser?.(authorizeUrl.toString());
|
|
621
|
+
const code = await listener.code;
|
|
622
|
+
const tokens = await tokenRequest(
|
|
623
|
+
server.tokenEndpoint,
|
|
624
|
+
options.fetch,
|
|
625
|
+
{
|
|
626
|
+
grant_type: "authorization_code",
|
|
627
|
+
code,
|
|
628
|
+
redirect_uri: listener.redirectUri,
|
|
629
|
+
client_id: clientId,
|
|
630
|
+
code_verifier: verifier,
|
|
631
|
+
resource: server.resource
|
|
632
|
+
},
|
|
633
|
+
"Sign-in failed"
|
|
634
|
+
);
|
|
635
|
+
const organizationId = options.organizationId ?? existing?.organizationId;
|
|
636
|
+
const stored = {
|
|
637
|
+
baseUrl: options.baseUrl,
|
|
638
|
+
clientId,
|
|
639
|
+
...storedTokens(tokens, void 0, Date.now()),
|
|
640
|
+
tokenEndpoint: server.tokenEndpoint,
|
|
641
|
+
...server.revocationEndpoint ? { revocationEndpoint: server.revocationEndpoint } : {},
|
|
642
|
+
...organizationId ? { organizationId } : {}
|
|
643
|
+
};
|
|
644
|
+
await options.store.withLock(() => options.store.write(stored));
|
|
645
|
+
return stored;
|
|
646
|
+
} finally {
|
|
647
|
+
listener.close();
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
17
651
|
// src/version.ts
|
|
18
|
-
var VERSION = "0.6.
|
|
652
|
+
var VERSION = "0.6.2";
|
|
19
653
|
|
|
20
654
|
// src/cli.ts
|
|
21
655
|
var DEFAULT_BASE_URL = "https://shipmail.to/api/v1";
|
|
@@ -166,29 +800,152 @@ function baseUrl(parsed, env) {
|
|
|
166
800
|
if (!raw) return DEFAULT_BASE_URL;
|
|
167
801
|
return validateBaseUrl(raw, env["SHIPMAIL_ALLOW_INSECURE_BASE_URL"] === "1");
|
|
168
802
|
}
|
|
169
|
-
|
|
803
|
+
var REFRESH_MARGIN_MS = 6e4;
|
|
804
|
+
function cliEnv(options) {
|
|
805
|
+
return options.env ?? processEnv;
|
|
806
|
+
}
|
|
807
|
+
async function credentialStore(options) {
|
|
808
|
+
if (options.credentialStore) return options.credentialStore;
|
|
809
|
+
const errOut = options.stderr ?? stderr;
|
|
810
|
+
return defaultCredentialStore({
|
|
811
|
+
env: cliEnv(options),
|
|
812
|
+
platform: process.platform,
|
|
813
|
+
run: runCommand,
|
|
814
|
+
notice: (message) => errOut.write(`${message}
|
|
815
|
+
`)
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
function isFresh(login2) {
|
|
819
|
+
return login2.expiresAt - Date.now() > REFRESH_MARGIN_MS;
|
|
820
|
+
}
|
|
821
|
+
function sameGeneration(a, b) {
|
|
822
|
+
return a.accessToken === b.accessToken && a.refreshToken === b.refreshToken;
|
|
823
|
+
}
|
|
824
|
+
async function freshLogin(stored, store, fetchFn) {
|
|
825
|
+
if (isFresh(stored)) return stored;
|
|
826
|
+
return store.withLock(async () => {
|
|
827
|
+
const current = await store.read(stored.baseUrl);
|
|
828
|
+
if (!current) throw new Error("You were signed out. Run shipmail login.");
|
|
829
|
+
if (isFresh(current)) return current;
|
|
830
|
+
try {
|
|
831
|
+
const refreshed = await refreshLogin(current, fetchFn);
|
|
832
|
+
const latest = await store.read(stored.baseUrl);
|
|
833
|
+
if (latest && sameGeneration(latest, current)) await store.write(refreshed);
|
|
834
|
+
return refreshed;
|
|
835
|
+
} catch (err) {
|
|
836
|
+
if (!(err instanceof OAuthError) || err.code !== "invalid_grant") throw err;
|
|
837
|
+
const latest = await store.read(stored.baseUrl);
|
|
838
|
+
if (latest && !sameGeneration(latest, current)) {
|
|
839
|
+
if (isFresh(latest)) return latest;
|
|
840
|
+
throw new Error(
|
|
841
|
+
"Your Shipmail login changed while it was refreshing. Run the command again.",
|
|
842
|
+
{ cause: err }
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
if (latest) await store.delete(stored.baseUrl);
|
|
846
|
+
throw new Error("Your Shipmail login has expired or was revoked. Run shipmail login.", {
|
|
847
|
+
cause: err
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
async function resolveCredential(parsed, options) {
|
|
853
|
+
const env = cliEnv(options);
|
|
170
854
|
const key = apiKey(parsed, env);
|
|
171
|
-
if (
|
|
172
|
-
|
|
855
|
+
if (key) return { kind: "api-key", token: key };
|
|
856
|
+
const store = await credentialStore(options);
|
|
857
|
+
const stored = await store.read(baseUrl(parsed, env));
|
|
858
|
+
if (!stored) {
|
|
859
|
+
throw new CliUsageError(
|
|
860
|
+
"Not signed in. Run shipmail login, set SHIPMAIL_API_KEY, or pass --api-key sm_live_..."
|
|
861
|
+
);
|
|
173
862
|
}
|
|
174
|
-
|
|
863
|
+
const current = await freshLogin(stored, store, options.fetch ?? fetch);
|
|
864
|
+
return {
|
|
865
|
+
kind: "oauth",
|
|
866
|
+
token: current.accessToken,
|
|
867
|
+
organizationId: getString(parsed.options, "organization") ?? env["SHIPMAIL_ORGANIZATION"] ?? current.organizationId
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
function organizationHeader(credential) {
|
|
871
|
+
return credential.kind === "oauth" && credential.organizationId ? { "X-Shipmail-Organization": credential.organizationId } : {};
|
|
175
872
|
}
|
|
176
|
-
function createClient(parsed, options) {
|
|
177
|
-
const env = options
|
|
873
|
+
async function createClient(parsed, options) {
|
|
874
|
+
const env = cliEnv(options);
|
|
875
|
+
const credential = await resolveCredential(parsed, options);
|
|
178
876
|
const config = {
|
|
179
|
-
apiKey:
|
|
877
|
+
apiKey: credential.token,
|
|
180
878
|
baseUrl: baseUrl(parsed, env),
|
|
879
|
+
defaultHeaders: organizationHeader(credential),
|
|
181
880
|
...options.fetch ? { fetch: options.fetch } : {}
|
|
182
881
|
};
|
|
183
882
|
return new ShipmailClient(config);
|
|
184
883
|
}
|
|
884
|
+
async function runLogin(parsed, options, out) {
|
|
885
|
+
const env = cliEnv(options);
|
|
886
|
+
const errOut = options.stderr ?? stderr;
|
|
887
|
+
const base = baseUrl(parsed, env);
|
|
888
|
+
const noBrowser = getBoolean(parsed.options, "no-browser") ?? false;
|
|
889
|
+
const stored = await login({
|
|
890
|
+
baseUrl: base,
|
|
891
|
+
fetch: options.fetch ?? fetch,
|
|
892
|
+
store: await credentialStore(options),
|
|
893
|
+
openBrowser: noBrowser ? null : options.openBrowser ?? openInBrowser,
|
|
894
|
+
print: (line) => errOut.write(`${line}
|
|
895
|
+
`),
|
|
896
|
+
timeoutMs: options.loginTimeoutMs ?? LOGIN_TIMEOUT_MS,
|
|
897
|
+
organizationId: getString(parsed.options, "organization")
|
|
898
|
+
});
|
|
899
|
+
const organization = stored.organizationId ? ` Default organization: ${stored.organizationId}.` : "";
|
|
900
|
+
out.write(`Signed in to ${new URL(base).origin}.${organization}
|
|
901
|
+
`);
|
|
902
|
+
}
|
|
903
|
+
async function runLogout(parsed, options, out) {
|
|
904
|
+
const env = cliEnv(options);
|
|
905
|
+
const errOut = options.stderr ?? stderr;
|
|
906
|
+
const base = baseUrl(parsed, env);
|
|
907
|
+
const origin = new URL(base).origin;
|
|
908
|
+
const store = await credentialStore(options);
|
|
909
|
+
const signedOut = await store.withLock(async () => {
|
|
910
|
+
const stored = await store.read(base);
|
|
911
|
+
if (!stored) return false;
|
|
912
|
+
if (stored.revocationEndpoint && stored.refreshToken) {
|
|
913
|
+
try {
|
|
914
|
+
await revokeToken(
|
|
915
|
+
stored.revocationEndpoint,
|
|
916
|
+
stored.clientId,
|
|
917
|
+
stored.refreshToken,
|
|
918
|
+
options.fetch ?? fetch
|
|
919
|
+
);
|
|
920
|
+
} catch (err) {
|
|
921
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
922
|
+
errOut.write(`Could not revoke the login at the server: ${reason}
|
|
923
|
+
`);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
const latest = await store.read(base);
|
|
927
|
+
if (latest && !sameGeneration(latest, stored)) {
|
|
928
|
+
throw new Error("A new login was saved while signing out. Run shipmail logout again.");
|
|
929
|
+
}
|
|
930
|
+
try {
|
|
931
|
+
await store.delete(base);
|
|
932
|
+
} catch (err) {
|
|
933
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
934
|
+
throw new Error(`${reason} The login is still saved on this computer.`, { cause: err });
|
|
935
|
+
}
|
|
936
|
+
return true;
|
|
937
|
+
});
|
|
938
|
+
out.write(signedOut ? `Signed out of ${origin}.
|
|
939
|
+
` : `Not signed in to ${origin}.
|
|
940
|
+
`);
|
|
941
|
+
}
|
|
185
942
|
function writeJson(out, value) {
|
|
186
943
|
out.write(`${JSON.stringify(value, null, 2)}
|
|
187
944
|
`);
|
|
188
945
|
}
|
|
189
946
|
async function readJsonInput(value) {
|
|
190
947
|
if (value === void 0) return void 0;
|
|
191
|
-
const raw = value.startsWith("@") ? await
|
|
948
|
+
const raw = value.startsWith("@") ? await readFile2(value.slice(1), "utf8") : value;
|
|
192
949
|
try {
|
|
193
950
|
return JSON.parse(raw);
|
|
194
951
|
} catch {
|
|
@@ -197,7 +954,7 @@ async function readJsonInput(value) {
|
|
|
197
954
|
}
|
|
198
955
|
async function readTextInput(value) {
|
|
199
956
|
if (value === void 0) return void 0;
|
|
200
|
-
if (value.startsWith("@")) return
|
|
957
|
+
if (value.startsWith("@")) return readFile2(value.slice(1), "utf8");
|
|
201
958
|
return value;
|
|
202
959
|
}
|
|
203
960
|
function isJsonObject(value) {
|
|
@@ -277,6 +1034,7 @@ async function newsletterCreateParams(options) {
|
|
|
277
1034
|
const archive_visibility = parseArchiveVisibility(options);
|
|
278
1035
|
const feed_entry_url = getString(options, "feed-entry-url");
|
|
279
1036
|
const styling_mode = parseStylingMode(options);
|
|
1037
|
+
const track_engagement = getBoolean(options, "track-engagement");
|
|
280
1038
|
const preview_text = getString(options, "preview-text");
|
|
281
1039
|
return {
|
|
282
1040
|
audience_id,
|
|
@@ -290,7 +1048,8 @@ async function newsletterCreateParams(options) {
|
|
|
290
1048
|
...send_window_hours !== void 0 ? { send_window_hours } : {},
|
|
291
1049
|
...archive_visibility !== void 0 ? { archive_visibility } : {},
|
|
292
1050
|
...feed_entry_url !== void 0 ? { feed_entry_url } : {},
|
|
293
|
-
...styling_mode !== void 0 ? { styling_mode } : {}
|
|
1051
|
+
...styling_mode !== void 0 ? { styling_mode } : {},
|
|
1052
|
+
...track_engagement !== void 0 ? { track_engagement } : {}
|
|
294
1053
|
};
|
|
295
1054
|
}
|
|
296
1055
|
async function newsletterUpdateParams(options) {
|
|
@@ -303,6 +1062,7 @@ async function newsletterUpdateParams(options) {
|
|
|
303
1062
|
const archive_visibility = parseArchiveVisibility(options);
|
|
304
1063
|
const feed_entry_url = getString(options, "feed-entry-url");
|
|
305
1064
|
const styling_mode = parseStylingMode(options);
|
|
1065
|
+
const track_engagement = getBoolean(options, "track-engagement");
|
|
306
1066
|
const blocksInput = getString(options, "blocks");
|
|
307
1067
|
const bodyHtmlInput = getString(options, "body-html");
|
|
308
1068
|
const bodyTextInput = getString(options, "body-text");
|
|
@@ -321,7 +1081,8 @@ async function newsletterUpdateParams(options) {
|
|
|
321
1081
|
...send_window_hours !== void 0 ? { send_window_hours } : {},
|
|
322
1082
|
...archive_visibility !== void 0 ? { archive_visibility } : {},
|
|
323
1083
|
...feed_entry_url !== void 0 ? { feed_entry_url } : {},
|
|
324
|
-
...styling_mode !== void 0 ? { styling_mode } : {}
|
|
1084
|
+
...styling_mode !== void 0 ? { styling_mode } : {},
|
|
1085
|
+
...track_engagement !== void 0 ? { track_engagement } : {}
|
|
325
1086
|
};
|
|
326
1087
|
if (Object.keys(params).length === 0) {
|
|
327
1088
|
throw new CliUsageError("Provide at least one field to update.");
|
|
@@ -349,7 +1110,9 @@ async function rawApiRequest(parsed, runOptions, method, path, body, authRequire
|
|
|
349
1110
|
...parseHeaders(getStringList(parsed.options, "header"))
|
|
350
1111
|
};
|
|
351
1112
|
if (authRequired) {
|
|
352
|
-
|
|
1113
|
+
const credential = await resolveCredential(parsed, runOptions);
|
|
1114
|
+
headers["Authorization"] = `Bearer ${credential.token}`;
|
|
1115
|
+
Object.assign(headers, organizationHeader(credential));
|
|
353
1116
|
} else {
|
|
354
1117
|
const key = apiKey(parsed, env);
|
|
355
1118
|
if (key) headers["Authorization"] = `Bearer ${key}`;
|
|
@@ -375,7 +1138,7 @@ async function rawApiRequest(parsed, runOptions, method, path, body, authRequire
|
|
|
375
1138
|
async function writeRawResponse(parsed, io, response) {
|
|
376
1139
|
const outputPath = getString(parsed.options, "output");
|
|
377
1140
|
if (outputPath) {
|
|
378
|
-
await
|
|
1141
|
+
await writeFile2(outputPath, Buffer.from(await response.arrayBuffer()));
|
|
379
1142
|
io.stdout.write(`${outputPath}
|
|
380
1143
|
`);
|
|
381
1144
|
return;
|
|
@@ -396,8 +1159,11 @@ function usage() {
|
|
|
396
1159
|
return `shipmail-cli v${VERSION}
|
|
397
1160
|
|
|
398
1161
|
Usage:
|
|
1162
|
+
shipmail login [--no-browser] [--organization org_...]
|
|
1163
|
+
shipmail logout
|
|
399
1164
|
shipmail status
|
|
400
1165
|
shipmail capabilities
|
|
1166
|
+
shipmail organization access
|
|
401
1167
|
shipmail api <METHOD> <PATH> [--data JSON|@file] [--output file]
|
|
402
1168
|
shipmail automations list
|
|
403
1169
|
shipmail automations create --name "Daily brief" --definition JSON|@file [--idempotency-key key]
|
|
@@ -427,7 +1193,7 @@ Usage:
|
|
|
427
1193
|
shipmail mailboxes identities <id>
|
|
428
1194
|
shipmail mailboxes rules <id>
|
|
429
1195
|
shipmail mailboxes forwarding <id>
|
|
430
|
-
shipmail mailboxes forwarding-add <id> --destination owner@example.com [--sender newsletter@example.com]
|
|
1196
|
+
shipmail mailboxes forwarding-add <id> --destination owner@example.com [--sender newsletter@example.com] [--recipient support@example.com]
|
|
431
1197
|
shipmail mailboxes forwarding-remove <id> <forwarding-id>
|
|
432
1198
|
shipmail mailboxes delivery-routing <id>
|
|
433
1199
|
shipmail mailboxes delivery-routing-set <id> --mode keep|fixed|round-robin [--destination-mailbox-id mbx_...] [--fallback-mailbox-id mbx_...]
|
|
@@ -471,8 +1237,8 @@ Usage:
|
|
|
471
1237
|
--name "July changelog" \\
|
|
472
1238
|
--subject "What shipped in July" \\
|
|
473
1239
|
--blocks @blocks.json \\
|
|
474
|
-
[--feed-entry-url https://example.com/post] [--styling-mode styled|plain]
|
|
475
|
-
shipmail newsletters update nws_789 --subject "Updated subject" --blocks @blocks-v2.json [--feed-entry-url https://example.com/post] [--styling-mode styled|plain]
|
|
1240
|
+
[--feed-entry-url https://example.com/post] [--styling-mode styled|plain] [--track-engagement true|false]
|
|
1241
|
+
shipmail newsletters update nws_789 --subject "Updated subject" --blocks @blocks-v2.json [--feed-entry-url https://example.com/post] [--styling-mode styled|plain] [--track-engagement true|false]
|
|
476
1242
|
shipmail newsletters preview <id>
|
|
477
1243
|
shipmail newsletters preflight <id>
|
|
478
1244
|
shipmail newsletters test-send <id> --recipient-email owner@example.com
|
|
@@ -502,9 +1268,9 @@ Usage:
|
|
|
502
1268
|
shipmail audiences subscribers resubscribe <audience-id> <subscriber-id> --consent-source "Signup form"
|
|
503
1269
|
shipmail audiences subscribers remove <audience-id> <subscriber-id>
|
|
504
1270
|
shipmail calendar events list --mailbox hello@example.com --from 2026-08-01T00:00:00Z --to 2026-08-08T00:00:00Z [--expand true] [--limit 100]
|
|
505
|
-
shipmail calendar events create --mailbox hello@example.com --title "Sync" --start 2026-08-01T14:00:00 [--timezone Europe/Paris] [--duration PT30M] [--invitation-language en|fr|es] [--attendees JSON|@file] [--reminders JSON|@file]
|
|
1271
|
+
shipmail calendar events create --mailbox hello@example.com --title "Sync" --start 2026-08-01T14:00:00 [--timezone Europe/Paris] [--duration PT30M] [--invitation-language en|fr|es] [--attendees JSON|@file] [--reminders JSON|@file] [--operation-key key] [--send-invitations true|false]
|
|
506
1272
|
shipmail calendar events get <event-id> --mailbox hello@example.com
|
|
507
|
-
shipmail calendar events update <event-id> --mailbox hello@example.com [--title "..."] [--start ...] [--invitation-language en|fr|es]
|
|
1273
|
+
shipmail calendar events update <event-id> --mailbox hello@example.com [--title "..."] [--start ...] [--invitation-language en|fr|es] [--operation-key key] [--expected-revision sha256] [--send-guest-updates true|false]
|
|
508
1274
|
shipmail calendar events delete <event-id> --mailbox hello@example.com
|
|
509
1275
|
shipmail calendar availability --mailbox hello@example.com --from 2026-08-03T00:00:00Z --to 2026-08-04T00:00:00Z [--duration 30] [--days 1,2,3,4,5] [--window-start 540] [--window-end 1020]
|
|
510
1276
|
shipmail booking-pages list [--limit 25] [--cursor cur]
|
|
@@ -521,27 +1287,60 @@ Conversation IDs:
|
|
|
521
1287
|
<thread-id> accepts a conversation ID (thd_...) or the mail-server thread ID. Store the
|
|
522
1288
|
conversation_id every response carries. thread_id is deprecated and unchanged.
|
|
523
1289
|
|
|
1290
|
+
Member access output:
|
|
1291
|
+
role is owner, admin, or member. mailbox_access.scope is all or selected. mailbox_ids is empty
|
|
1292
|
+
for all; selected IDs may be limited to mailboxes the API key can reach.
|
|
1293
|
+
|
|
524
1294
|
Newsletter styling:
|
|
525
1295
|
--styling-mode <styled|plain> styled applies Shipmail's email theme. plain sends your HTML without injected styles, width, or centering, so the reader's email client styles it.
|
|
526
1296
|
|
|
1297
|
+
Newsletter tracking:
|
|
1298
|
+
--track-engagement <true|false> Track opens and clicks for this issue. Off by default. Changes only while the issue is a draft or scheduled for later.
|
|
1299
|
+
|
|
527
1300
|
Newsletter block prose:
|
|
528
1301
|
Paragraph, quote, callout, list-item, and column bodies accept bare text or sanitized inline HTML. Use <p> and <br> for line breaks.
|
|
529
1302
|
|
|
530
1303
|
Newsletter feeds:
|
|
531
1304
|
--feed-entry-url <url> Link this issue's Atom feed title to an http(s) URL instead of its Shipmail archive.
|
|
532
1305
|
|
|
1306
|
+
Sign-in:
|
|
1307
|
+
Run shipmail login once to sign in through the browser instead of using an API key.
|
|
1308
|
+
Run shipmail login --help for details.
|
|
1309
|
+
|
|
533
1310
|
Global options:
|
|
534
|
-
--api-key <key> API key. Defaults to SHIPMAIL_API_KEY.
|
|
1311
|
+
--api-key <key> API key. Defaults to SHIPMAIL_API_KEY, then the shipmail login session.
|
|
1312
|
+
--organization <id> Organization for a login that can reach several. Defaults to
|
|
1313
|
+
SHIPMAIL_ORGANIZATION, then the one saved by shipmail login.
|
|
535
1314
|
--base-url <url> API base URL. Defaults to SHIPMAIL_BASE_URL or ${DEFAULT_BASE_URL}.
|
|
536
1315
|
--header "K: V" Extra header for the generic api command. Repeatable.
|
|
537
1316
|
|
|
538
1317
|
Environment:
|
|
539
1318
|
SHIPMAIL_API_KEY API key for authenticated commands.
|
|
1319
|
+
SHIPMAIL_ORGANIZATION Organization ID sent with a shipmail login session.
|
|
540
1320
|
SHIPMAIL_BASE_URL Optional API base URL. Must be https on shipmail.to.
|
|
541
1321
|
SHIPMAIL_ALLOW_INSECURE_BASE_URL=1
|
|
542
1322
|
Permit a non-https or non-shipmail.to base URL (development only).
|
|
543
1323
|
`;
|
|
544
1324
|
}
|
|
1325
|
+
function loginUsage() {
|
|
1326
|
+
return `Usage:
|
|
1327
|
+
shipmail login [--no-browser] [--organization org_...]
|
|
1328
|
+
shipmail logout
|
|
1329
|
+
|
|
1330
|
+
shipmail login opens Shipmail in your browser. Sign in, choose the organizations the CLI may
|
|
1331
|
+
reach, and approve. The CLI then runs commands without an API key.
|
|
1332
|
+
|
|
1333
|
+
--no-browser Print the sign-in URL without opening a browser.
|
|
1334
|
+
--organization <id> Save a default organization, for a login that can reach several.
|
|
1335
|
+
|
|
1336
|
+
The login is saved in the macOS Keychain, in the Secret Service through secret-tool on Linux,
|
|
1337
|
+
or otherwise in $XDG_CONFIG_HOME/shipmail/credentials.json (~/.config/shipmail/ by default),
|
|
1338
|
+
readable only by you. It refreshes itself. An API key from --api-key or SHIPMAIL_API_KEY takes
|
|
1339
|
+
precedence over the login.
|
|
1340
|
+
|
|
1341
|
+
shipmail logout revokes the login at Shipmail and deletes it from this computer.
|
|
1342
|
+
`;
|
|
1343
|
+
}
|
|
545
1344
|
function requirePositional(positional, index, label) {
|
|
546
1345
|
const value = positional[index];
|
|
547
1346
|
if (!value) throw new CliUsageError(`Missing ${label}.`);
|
|
@@ -764,7 +1563,7 @@ async function runMailboxes(client, parsed, out) {
|
|
|
764
1563
|
if (!passwordValue && !passwordFile && !generatePassword) {
|
|
765
1564
|
throw new CliUsageError("Missing --password, --password-file, or --generate-password.");
|
|
766
1565
|
}
|
|
767
|
-
const password = passwordFile ? (await
|
|
1566
|
+
const password = passwordFile ? (await readFile2(passwordFile, "utf8")).replace(/\r?\n$/, "") : passwordValue;
|
|
768
1567
|
const credentials = generatePassword ? { generate_password: true } : password ? { password } : void 0;
|
|
769
1568
|
if (!credentials) {
|
|
770
1569
|
throw new CliUsageError("Mailbox password must not be empty.");
|
|
@@ -901,7 +1700,8 @@ async function runMailboxes(client, parsed, out) {
|
|
|
901
1700
|
requirePositional(parsed.positional, 2, "mailbox id"),
|
|
902
1701
|
{
|
|
903
1702
|
destination: getRequiredString(parsed.options, "destination"),
|
|
904
|
-
sender: getString(parsed.options, "sender") ?? null
|
|
1703
|
+
sender: getString(parsed.options, "sender") ?? null,
|
|
1704
|
+
recipient: getString(parsed.options, "recipient") ?? null
|
|
905
1705
|
}
|
|
906
1706
|
)
|
|
907
1707
|
);
|
|
@@ -965,7 +1765,7 @@ async function runMailboxes(client, parsed, out) {
|
|
|
965
1765
|
if (subcommand === "stage-attachment") {
|
|
966
1766
|
const mailboxId = requirePositional(parsed.positional, 2, "mailbox id");
|
|
967
1767
|
const filePath = requirePositional(parsed.positional, 3, "file");
|
|
968
|
-
const data = await
|
|
1768
|
+
const data = await readFile2(realpathSync(filePath));
|
|
969
1769
|
writeJson(
|
|
970
1770
|
out,
|
|
971
1771
|
await client.mailboxes.stageAttachment(mailboxId, {
|
|
@@ -979,14 +1779,14 @@ async function runMailboxes(client, parsed, out) {
|
|
|
979
1779
|
if (subcommand === "prepare-attachment-upload") {
|
|
980
1780
|
const mailboxId = requirePositional(parsed.positional, 2, "mailbox id");
|
|
981
1781
|
const filePath = requirePositional(parsed.positional, 3, "file");
|
|
982
|
-
const data = await
|
|
1782
|
+
const data = await readFile2(realpathSync(filePath));
|
|
983
1783
|
writeJson(
|
|
984
1784
|
out,
|
|
985
1785
|
await client.mailboxes.prepareStagedAttachmentUpload(mailboxId, {
|
|
986
1786
|
filename: getString(parsed.options, "filename") ?? filePath.split(/[\\/]/).at(-1) ?? "attachment",
|
|
987
1787
|
content_type: getString(parsed.options, "content-type") ?? "application/octet-stream",
|
|
988
1788
|
size: data.byteLength,
|
|
989
|
-
sha256:
|
|
1789
|
+
sha256: createHash2("sha256").update(data).digest("hex")
|
|
990
1790
|
})
|
|
991
1791
|
);
|
|
992
1792
|
return;
|
|
@@ -1784,7 +2584,7 @@ async function runNewsletterAssets(client, parsed, out, fetchImpl) {
|
|
|
1784
2584
|
}
|
|
1785
2585
|
if (subcommand === "upload") {
|
|
1786
2586
|
const filePath = requirePositional(parsed.positional, 2, "file");
|
|
1787
|
-
const data = await
|
|
2587
|
+
const data = await readFile2(realpathSync(filePath));
|
|
1788
2588
|
const filename = getString(parsed.options, "filename") ?? filePath.split(/[\\/]/).at(-1) ?? "newsletter-asset";
|
|
1789
2589
|
const contentType = newsletterAssetContentType(
|
|
1790
2590
|
filePath,
|
|
@@ -1795,7 +2595,7 @@ async function runNewsletterAssets(client, parsed, out, fetchImpl) {
|
|
|
1795
2595
|
filename,
|
|
1796
2596
|
content_type: contentType,
|
|
1797
2597
|
size: data.byteLength,
|
|
1798
|
-
sha256:
|
|
2598
|
+
sha256: createHash2("sha256").update(data).digest("hex")
|
|
1799
2599
|
});
|
|
1800
2600
|
await putPreparedNewsletterBytes(fetchImpl, prepared.upload_url, prepared.upload_headers, data);
|
|
1801
2601
|
if (prepared.kind === "video") {
|
|
@@ -1858,7 +2658,7 @@ function newsletterAssetContentType(filePath, declared) {
|
|
|
1858
2658
|
async function readNewsletterVideoThumbnail(options) {
|
|
1859
2659
|
const thumbnailPath = getString(options, "thumbnail");
|
|
1860
2660
|
if (!thumbnailPath) throw new CliUsageError("Video uploads require --thumbnail <jpeg-file>.");
|
|
1861
|
-
const bytes = await
|
|
2661
|
+
const bytes = await readFile2(realpathSync(thumbnailPath));
|
|
1862
2662
|
if (bytes.byteLength < 3 || bytes[0] !== 255 || bytes[1] !== 216 || bytes[2] !== 255) {
|
|
1863
2663
|
throw new CliUsageError("--thumbnail must be a JPEG file.");
|
|
1864
2664
|
}
|
|
@@ -2003,6 +2803,8 @@ async function runCalendarEvents(client, parsed, out) {
|
|
|
2003
2803
|
title: getRequiredString(options, "title"),
|
|
2004
2804
|
start: getRequiredString(options, "start"),
|
|
2005
2805
|
calendar_id: getString(options, "calendar-id"),
|
|
2806
|
+
operation_key: getString(options, "operation-key"),
|
|
2807
|
+
send_invitations: getBoolean(options, "send-invitations"),
|
|
2006
2808
|
...await calendarEventFields(options)
|
|
2007
2809
|
};
|
|
2008
2810
|
writeJson(out, await client.calendar.events.create(params));
|
|
@@ -2022,6 +2824,9 @@ async function runCalendarEvents(client, parsed, out) {
|
|
|
2022
2824
|
mailbox: getRequiredString(options, "mailbox"),
|
|
2023
2825
|
title: getString(options, "title"),
|
|
2024
2826
|
start: getString(options, "start"),
|
|
2827
|
+
operation_key: getString(options, "operation-key"),
|
|
2828
|
+
expected_revision: getString(options, "expected-revision"),
|
|
2829
|
+
send_guest_updates: getBoolean(options, "send-guest-updates"),
|
|
2025
2830
|
...await calendarEventFields(options)
|
|
2026
2831
|
};
|
|
2027
2832
|
writeJson(out, await client.calendar.events.update(id, params));
|
|
@@ -2199,6 +3004,11 @@ ${JSON.stringify(err.body, null, 2)}
|
|
|
2199
3004
|
const messageId = shipmailError instanceof ReconciliationRequiredError && shipmailError.messageId ? ` message_id=${shipmailError.messageId}` : "";
|
|
2200
3005
|
errOut.write(`Error: ${shipmailError.message}${status}${requestId}${messageId}
|
|
2201
3006
|
`);
|
|
3007
|
+
if (shipmailError.actionUrl) {
|
|
3008
|
+
const reason = shipmailError.reason ? ` (${shipmailError.reason})` : "";
|
|
3009
|
+
errOut.write(`Ask the organization owner to open${reason}: ${shipmailError.actionUrl}
|
|
3010
|
+
`);
|
|
3011
|
+
}
|
|
2202
3012
|
return;
|
|
2203
3013
|
}
|
|
2204
3014
|
errOut.write(`Error: ${err instanceof Error ? err.message : String(err)}
|
|
@@ -2215,6 +3025,10 @@ async function runCli(argv, options = {}) {
|
|
|
2215
3025
|
`);
|
|
2216
3026
|
return 0;
|
|
2217
3027
|
}
|
|
3028
|
+
if ((command === "login" || command === "logout") && optionValue(parsed.options, "help")) {
|
|
3029
|
+
out.write(loginUsage());
|
|
3030
|
+
return 0;
|
|
3031
|
+
}
|
|
2218
3032
|
if (!command || command === "help" || command === "-h" || optionValue(parsed.options, "help")) {
|
|
2219
3033
|
out.write(usage());
|
|
2220
3034
|
return 0;
|
|
@@ -2244,8 +3058,21 @@ async function runCli(argv, options = {}) {
|
|
|
2244
3058
|
await writeRawResponse(parsed, { stdout: out }, response);
|
|
2245
3059
|
return 0;
|
|
2246
3060
|
}
|
|
2247
|
-
|
|
2248
|
-
|
|
3061
|
+
if (command === "login") {
|
|
3062
|
+
await runLogin(parsed, options, out);
|
|
3063
|
+
return 0;
|
|
3064
|
+
}
|
|
3065
|
+
if (command === "logout") {
|
|
3066
|
+
await runLogout(parsed, options, out);
|
|
3067
|
+
return 0;
|
|
3068
|
+
}
|
|
3069
|
+
const client = await createClient(parsed, options);
|
|
3070
|
+
if (command === "organization") {
|
|
3071
|
+
if (parsed.positional[1] !== "access") {
|
|
3072
|
+
throw new CliUsageError("Unknown organization command.");
|
|
3073
|
+
}
|
|
3074
|
+
writeJson(out, await client.organization.access());
|
|
3075
|
+
} else if (command === "automations") await runAutomations(client, parsed, out);
|
|
2249
3076
|
else if (command === "domains") await runDomains(client, parsed, out);
|
|
2250
3077
|
else if (command === "mailboxes") await runMailboxes(client, parsed, out);
|
|
2251
3078
|
else if (command === "members") await runMembers(client, parsed, out);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shipmail-cli",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.2",
|
|
4
4
|
"description": "Official CLI for the Shipmail API",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -41,7 +41,8 @@
|
|
|
41
41
|
"access": "public"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"shipmail": "0.6.
|
|
44
|
+
"shipmail": "0.6.3",
|
|
45
|
+
"zod": "^4.6.5"
|
|
45
46
|
},
|
|
46
47
|
"devDependencies": {
|
|
47
48
|
"tsup": "^8.4.0",
|