@letterapp/cli 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +203 -24
- package/dist/index.js +1533 -54
- package/dist/index.js.map +1 -0
- package/package.json +14 -5
- package/dist/commands/login.js +0 -151
- package/dist/commands/registry.js +0 -21
- package/dist/lib/api.js +0 -29
- package/dist/lib/args.js +0 -49
- package/dist/lib/browser.js +0 -33
- package/dist/lib/config.js +0 -35
- package/dist/lib/env-file.js +0 -45
- package/dist/lib/pm.js +0 -72
- package/dist/lib/ui.js +0 -57
package/dist/index.js
CHANGED
|
@@ -1,56 +1,1535 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/output.ts
|
|
7
|
+
import chalk from "chalk";
|
|
8
|
+
import ora from "ora";
|
|
9
|
+
import { createInterface } from "readline";
|
|
10
|
+
var jsonMode = false;
|
|
11
|
+
function setJsonMode(enabled) {
|
|
12
|
+
jsonMode = enabled;
|
|
13
|
+
}
|
|
14
|
+
function isJsonMode() {
|
|
15
|
+
return jsonMode;
|
|
16
|
+
}
|
|
17
|
+
function printJson(data) {
|
|
18
|
+
console.log(JSON.stringify(data, null, 2));
|
|
19
|
+
}
|
|
20
|
+
function log(msg = "") {
|
|
21
|
+
if (jsonMode) return;
|
|
22
|
+
console.log(msg);
|
|
23
|
+
}
|
|
24
|
+
function printSuccess(msg) {
|
|
25
|
+
if (jsonMode) return;
|
|
26
|
+
console.log(chalk.green("\u2713") + " " + msg);
|
|
27
|
+
}
|
|
28
|
+
function printInfo(msg) {
|
|
29
|
+
if (jsonMode) return;
|
|
30
|
+
console.log(chalk.cyan("\u203A") + " " + msg);
|
|
31
|
+
}
|
|
32
|
+
function printWarning(msg) {
|
|
33
|
+
if (jsonMode) return;
|
|
34
|
+
console.log(chalk.yellow("!") + " " + msg);
|
|
35
|
+
}
|
|
36
|
+
function printError(err) {
|
|
37
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
38
|
+
if (jsonMode) {
|
|
39
|
+
printJson({ error: msg });
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
console.error(chalk.red("\u2717") + " " + msg);
|
|
43
|
+
}
|
|
44
|
+
function banner() {
|
|
45
|
+
if (jsonMode) return;
|
|
46
|
+
console.log("");
|
|
47
|
+
console.log(" " + chalk.bold("Letter") + chalk.red(".") + " " + chalk.dim("CLI"));
|
|
48
|
+
console.log("");
|
|
49
|
+
}
|
|
50
|
+
function spinner(text) {
|
|
51
|
+
return ora({ text, color: "cyan", isEnabled: !jsonMode });
|
|
52
|
+
}
|
|
53
|
+
function prompt(question) {
|
|
54
|
+
if (!process.stdin.isTTY) return Promise.resolve("");
|
|
55
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
56
|
+
return new Promise((resolve) => {
|
|
57
|
+
rl.question(question, (answer) => {
|
|
58
|
+
rl.close();
|
|
59
|
+
resolve(answer.trim());
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
function emit(data) {
|
|
64
|
+
if (jsonMode) {
|
|
65
|
+
printJson(data);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (data && typeof data === "object" && Array.isArray(data.data)) {
|
|
69
|
+
const env = data;
|
|
70
|
+
if (env.data.length === 0) {
|
|
71
|
+
console.log(chalk.dim("(none)"));
|
|
72
|
+
} else {
|
|
73
|
+
for (const row of env.data) {
|
|
74
|
+
const id = String(row.id ?? row.slug ?? row.external_id ?? "");
|
|
75
|
+
const label = row.name ?? row.email ?? row.domain ?? row.subject ?? "";
|
|
76
|
+
console.log(`${chalk.bold(id)}${label ? " " + label : ""}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (env.next_cursor) {
|
|
80
|
+
console.log(chalk.dim(`
|
|
81
|
+
\u203A more: --cursor ${env.next_cursor}`));
|
|
82
|
+
}
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
printJson(data);
|
|
86
|
+
}
|
|
87
|
+
var c = chalk;
|
|
88
|
+
|
|
89
|
+
// src/config.ts
|
|
90
|
+
import Conf from "conf";
|
|
91
|
+
import { homedir } from "os";
|
|
92
|
+
import path from "path";
|
|
93
|
+
import { mkdir, readFile, writeFile, rm } from "fs/promises";
|
|
94
|
+
var DEFAULT_BASE_URL = "https://api.letter.app";
|
|
95
|
+
var config = new Conf({
|
|
96
|
+
projectName: "letterapp-cli",
|
|
97
|
+
schema: {
|
|
98
|
+
baseUrl: { type: "string", default: DEFAULT_BASE_URL }
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
function getBaseUrl(flag) {
|
|
102
|
+
const raw = flag || process.env.LETTER_BASE_URL || config.get("baseUrl") || DEFAULT_BASE_URL;
|
|
103
|
+
return raw.replace(/\/$/, "");
|
|
104
|
+
}
|
|
105
|
+
function setBaseUrl(url) {
|
|
106
|
+
config.set("baseUrl", url.replace(/\/$/, ""));
|
|
107
|
+
}
|
|
108
|
+
function getConfigPath() {
|
|
109
|
+
return config.path;
|
|
110
|
+
}
|
|
111
|
+
function resetConfig() {
|
|
112
|
+
config.clear();
|
|
113
|
+
}
|
|
114
|
+
async function resolveProjectSlug(flag) {
|
|
115
|
+
if (flag) return flag;
|
|
116
|
+
if (process.env.LETTER_PROJECT) return process.env.LETTER_PROJECT;
|
|
117
|
+
const cred = await readCredential();
|
|
118
|
+
if (cred?.project?.slug) return cred.project.slug;
|
|
119
|
+
throw new Error(
|
|
120
|
+
"No project. Pass --project <slug>, set LETTER_PROJECT, or run `letter login`."
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
function credentialsPath() {
|
|
124
|
+
return path.join(homedir(), ".letter", "credentials.json");
|
|
125
|
+
}
|
|
126
|
+
async function saveCredential(cred) {
|
|
127
|
+
const file = credentialsPath();
|
|
128
|
+
await mkdir(path.dirname(file), { recursive: true, mode: 448 });
|
|
129
|
+
await writeFile(file, `${JSON.stringify(cred, null, 2)}
|
|
130
|
+
`, { mode: 384 });
|
|
131
|
+
return file;
|
|
132
|
+
}
|
|
133
|
+
async function readCredential() {
|
|
134
|
+
try {
|
|
135
|
+
const raw = await readFile(credentialsPath(), "utf8");
|
|
136
|
+
return JSON.parse(raw);
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
async function clearCredential() {
|
|
142
|
+
await rm(credentialsPath(), { force: true });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// src/client.ts
|
|
146
|
+
var USER_AGENT = "@letterapp/cli";
|
|
147
|
+
async function startDeviceAuth(base) {
|
|
148
|
+
const res = await fetch(`${base}/v1/cli/auth/start`, {
|
|
149
|
+
method: "POST",
|
|
150
|
+
headers: { "content-type": "application/json", "user-agent": USER_AGENT },
|
|
151
|
+
body: "{}"
|
|
152
|
+
});
|
|
153
|
+
if (!res.ok) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
`Could not start login (HTTP ${res.status}). Is ${base} reachable?`
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
return await res.json();
|
|
159
|
+
}
|
|
160
|
+
async function pollDeviceAuth(base, deviceCode) {
|
|
161
|
+
const res = await fetch(`${base}/v1/cli/auth/poll`, {
|
|
162
|
+
method: "POST",
|
|
163
|
+
headers: { "content-type": "application/json", "user-agent": USER_AGENT },
|
|
164
|
+
body: JSON.stringify({ device_code: deviceCode })
|
|
165
|
+
});
|
|
166
|
+
if (res.status === 429) {
|
|
167
|
+
const retryAfter = Number(res.headers.get("retry-after") ?? "5");
|
|
168
|
+
return { status: "slow_down", retryAfter };
|
|
169
|
+
}
|
|
170
|
+
if (!res.ok) {
|
|
171
|
+
throw new Error(`Login poll failed (HTTP ${res.status}).`);
|
|
172
|
+
}
|
|
173
|
+
return await res.json();
|
|
174
|
+
}
|
|
175
|
+
var LetterClient = class {
|
|
176
|
+
maxRetries = 3;
|
|
177
|
+
async resolveAuth() {
|
|
178
|
+
const cred = await readCredential();
|
|
179
|
+
const token = process.env.LETTER_API_KEY || cred?.apiKey || "";
|
|
180
|
+
if (!token) {
|
|
181
|
+
throw new Error(
|
|
182
|
+
"Not connected. Run `letter login` (or set LETTER_API_KEY) first."
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
const base = getBaseUrl(process.env.LETTER_API_KEY ? void 0 : cred?.baseUrl);
|
|
186
|
+
return { base, token };
|
|
187
|
+
}
|
|
188
|
+
async request(method, path3, attempt = 1) {
|
|
189
|
+
const { base, token } = await this.resolveAuth();
|
|
190
|
+
const res = await fetch(`${base}${path3}`, {
|
|
191
|
+
method,
|
|
192
|
+
headers: {
|
|
193
|
+
authorization: `Bearer ${token}`,
|
|
194
|
+
accept: "application/json",
|
|
195
|
+
"user-agent": USER_AGENT
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
if (res.status === 429 && attempt <= this.maxRetries) {
|
|
199
|
+
const retryAfter = Number(res.headers.get("retry-after") || 2);
|
|
200
|
+
await new Promise((r) => setTimeout(r, retryAfter * 1e3 * attempt));
|
|
201
|
+
return this.request(method, path3, attempt + 1);
|
|
202
|
+
}
|
|
203
|
+
const data = res.status === 204 ? {} : await res.json();
|
|
204
|
+
if (!res.ok) {
|
|
205
|
+
const msg = data?.error?.message || `HTTP ${res.status}`;
|
|
206
|
+
const err = new Error(msg);
|
|
207
|
+
err.status = res.status;
|
|
208
|
+
throw err;
|
|
209
|
+
}
|
|
210
|
+
return { status: res.status, data };
|
|
211
|
+
}
|
|
212
|
+
get(path3) {
|
|
213
|
+
return this.request("GET", path3);
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
var client = new LetterClient();
|
|
217
|
+
var ManagementClient = class {
|
|
218
|
+
maxRetries = 3;
|
|
219
|
+
async resolveAuth() {
|
|
220
|
+
const cred = await readCredential();
|
|
221
|
+
const token = process.env.LETTER_PAT || cred?.pat || "";
|
|
222
|
+
if (!token) {
|
|
223
|
+
throw new Error(
|
|
224
|
+
"Not connected for management. Run `letter login` (or set LETTER_PAT) first."
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
const base = getBaseUrl(process.env.LETTER_PAT ? void 0 : cred?.baseUrl);
|
|
228
|
+
return { base, token };
|
|
229
|
+
}
|
|
230
|
+
async request(method, path3, init = {}, attempt = 1) {
|
|
231
|
+
const { base, token } = await this.resolveAuth();
|
|
232
|
+
let url = `${base}${path3}`;
|
|
233
|
+
if (init.query) {
|
|
234
|
+
const qs = new URLSearchParams();
|
|
235
|
+
for (const [k, v] of Object.entries(init.query)) {
|
|
236
|
+
if (v !== void 0 && v !== "") qs.set(k, String(v));
|
|
237
|
+
}
|
|
238
|
+
const s = qs.toString();
|
|
239
|
+
if (s) url += `?${s}`;
|
|
240
|
+
}
|
|
241
|
+
const headers = {
|
|
242
|
+
authorization: `Bearer ${token}`,
|
|
243
|
+
accept: "application/json",
|
|
244
|
+
"user-agent": USER_AGENT
|
|
245
|
+
};
|
|
246
|
+
let body;
|
|
247
|
+
if (init.form) {
|
|
248
|
+
body = init.form;
|
|
249
|
+
} else if (init.body !== void 0) {
|
|
250
|
+
headers["content-type"] = "application/json";
|
|
251
|
+
body = JSON.stringify(init.body);
|
|
252
|
+
}
|
|
253
|
+
const res = await fetch(url, { method, headers, body });
|
|
254
|
+
if (res.status === 429 && attempt <= this.maxRetries) {
|
|
255
|
+
const retryAfter = Number(res.headers.get("retry-after") || 2);
|
|
256
|
+
await new Promise((r) => setTimeout(r, retryAfter * 1e3 * attempt));
|
|
257
|
+
return this.request(method, path3, init, attempt + 1);
|
|
258
|
+
}
|
|
259
|
+
const text = await res.text();
|
|
260
|
+
const data = text ? JSON.parse(text) : {};
|
|
261
|
+
if (!res.ok) {
|
|
262
|
+
const msg = data?.error?.message || `HTTP ${res.status}`;
|
|
263
|
+
const err = new Error(msg);
|
|
264
|
+
err.status = res.status;
|
|
265
|
+
throw err;
|
|
266
|
+
}
|
|
267
|
+
return { status: res.status, data };
|
|
268
|
+
}
|
|
269
|
+
get(path3, query) {
|
|
270
|
+
return this.request("GET", path3, { query });
|
|
271
|
+
}
|
|
272
|
+
post(path3, body) {
|
|
273
|
+
return this.request("POST", path3, { body });
|
|
274
|
+
}
|
|
275
|
+
postForm(path3, form) {
|
|
276
|
+
return this.request("POST", path3, { form });
|
|
277
|
+
}
|
|
278
|
+
patch(path3, body) {
|
|
279
|
+
return this.request("PATCH", path3, { body });
|
|
280
|
+
}
|
|
281
|
+
put(path3, body) {
|
|
282
|
+
return this.request("PUT", path3, { body });
|
|
283
|
+
}
|
|
284
|
+
delete(path3) {
|
|
285
|
+
return this.request("DELETE", path3);
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
var mgmt = new ManagementClient();
|
|
289
|
+
|
|
290
|
+
// src/browser.ts
|
|
291
|
+
import { spawn } from "child_process";
|
|
292
|
+
function openUrl(url) {
|
|
293
|
+
const platform = process.platform;
|
|
294
|
+
let command;
|
|
295
|
+
let args;
|
|
296
|
+
if (platform === "darwin") {
|
|
297
|
+
command = "open";
|
|
298
|
+
args = [url];
|
|
299
|
+
} else if (platform === "win32") {
|
|
300
|
+
command = "cmd";
|
|
301
|
+
args = ["/c", "start", "", url];
|
|
302
|
+
} else {
|
|
303
|
+
command = "xdg-open";
|
|
304
|
+
args = [url];
|
|
305
|
+
}
|
|
306
|
+
try {
|
|
307
|
+
const child = spawn(command, args, { stdio: "ignore", detached: true });
|
|
308
|
+
child.on("error", () => {
|
|
309
|
+
});
|
|
310
|
+
child.unref();
|
|
311
|
+
return true;
|
|
312
|
+
} catch {
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// src/env-file.ts
|
|
318
|
+
import path2 from "path";
|
|
319
|
+
import { readFile as readFile2, writeFile as writeFile2, stat } from "fs/promises";
|
|
320
|
+
async function upsertEnv(cwd, file, entries) {
|
|
321
|
+
const filePath = path2.join(cwd, file);
|
|
322
|
+
let contents = "";
|
|
323
|
+
try {
|
|
324
|
+
contents = await readFile2(filePath, "utf8");
|
|
325
|
+
} catch {
|
|
326
|
+
contents = "";
|
|
327
|
+
}
|
|
328
|
+
let next = contents;
|
|
329
|
+
for (const [key, value] of Object.entries(entries)) {
|
|
330
|
+
const line = `${key}=${value}`;
|
|
331
|
+
const re = new RegExp(`^${escapeRegExp(key)}=.*$`, "m");
|
|
332
|
+
if (re.test(next)) {
|
|
333
|
+
next = next.replace(re, line);
|
|
334
|
+
} else {
|
|
335
|
+
if (next.length && !next.endsWith("\n")) next += "\n";
|
|
336
|
+
next += `${line}
|
|
337
|
+
`;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
await writeFile2(filePath, next, "utf8");
|
|
341
|
+
return filePath;
|
|
342
|
+
}
|
|
343
|
+
async function fileExists(cwd, name) {
|
|
344
|
+
try {
|
|
345
|
+
await stat(path2.join(cwd, name));
|
|
346
|
+
return true;
|
|
347
|
+
} catch {
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
function escapeRegExp(s) {
|
|
352
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// src/pm.ts
|
|
356
|
+
import { spawn as spawn2 } from "child_process";
|
|
357
|
+
import { readFile as readFile3, readdir } from "fs/promises";
|
|
358
|
+
async function detectPackageManager(cwd) {
|
|
359
|
+
if (await fileExists(cwd, "pnpm-lock.yaml")) return "pnpm";
|
|
360
|
+
if (await fileExists(cwd, "yarn.lock")) return "yarn";
|
|
361
|
+
if (await fileExists(cwd, "bun.lockb")) return "bun";
|
|
362
|
+
if (await fileExists(cwd, "package-lock.json")) return "npm";
|
|
363
|
+
const ua = process.env.npm_config_user_agent ?? "";
|
|
364
|
+
if (ua.startsWith("pnpm")) return "pnpm";
|
|
365
|
+
if (ua.startsWith("yarn")) return "yarn";
|
|
366
|
+
if (ua.startsWith("bun")) return "bun";
|
|
367
|
+
return "npm";
|
|
368
|
+
}
|
|
369
|
+
async function detectFramework(cwd) {
|
|
370
|
+
try {
|
|
371
|
+
const pkg = JSON.parse(await readFile3(`${cwd}/package.json`, "utf8"));
|
|
372
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
373
|
+
if (deps.next) return "Next.js";
|
|
374
|
+
if (deps.nuxt) return "Nuxt";
|
|
375
|
+
if (deps["@remix-run/node"] || deps["@remix-run/react"]) return "Remix";
|
|
376
|
+
if (deps.express) return "Express";
|
|
377
|
+
if (deps.fastify) return "Fastify";
|
|
378
|
+
if (deps.hono) return "Hono";
|
|
379
|
+
if (deps["@sveltejs/kit"]) return "SvelteKit";
|
|
380
|
+
return null;
|
|
381
|
+
} catch {
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
async function detectStack(cwd) {
|
|
386
|
+
if (await fileExists(cwd, "package.json")) {
|
|
387
|
+
return { runtime: "node", framework: await detectFramework(cwd) };
|
|
388
|
+
}
|
|
389
|
+
if (await fileExists(cwd, "pyproject.toml") || await fileExists(cwd, "requirements.txt") || await fileExists(cwd, "Pipfile") || await fileExists(cwd, "setup.py")) {
|
|
390
|
+
return { runtime: "python", framework: await detectPythonFramework(cwd) };
|
|
391
|
+
}
|
|
392
|
+
if (await fileExists(cwd, "Gemfile") || await hasGemspec(cwd)) {
|
|
393
|
+
return { runtime: "ruby", framework: await detectRubyFramework(cwd) };
|
|
394
|
+
}
|
|
395
|
+
if (await fileExists(cwd, "go.mod")) return { runtime: "go", framework: null };
|
|
396
|
+
if (await fileExists(cwd, "composer.json")) {
|
|
397
|
+
return { runtime: "php", framework: await detectPhpFramework(cwd) };
|
|
398
|
+
}
|
|
399
|
+
return { runtime: "other", framework: null };
|
|
400
|
+
}
|
|
401
|
+
function stackLabel(stack) {
|
|
402
|
+
if (stack.framework) return stack.framework;
|
|
403
|
+
switch (stack.runtime) {
|
|
404
|
+
case "node":
|
|
405
|
+
return "a Node.js project";
|
|
406
|
+
case "python":
|
|
407
|
+
return "a Python project";
|
|
408
|
+
case "ruby":
|
|
409
|
+
return "a Ruby project";
|
|
410
|
+
case "go":
|
|
411
|
+
return "a Go project";
|
|
412
|
+
case "php":
|
|
413
|
+
return "a PHP project";
|
|
414
|
+
default:
|
|
415
|
+
return "your project";
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
function languageName(stack) {
|
|
419
|
+
switch (stack.runtime) {
|
|
420
|
+
case "node":
|
|
421
|
+
return "TypeScript";
|
|
422
|
+
case "python":
|
|
423
|
+
return "Python";
|
|
424
|
+
case "ruby":
|
|
425
|
+
return "Ruby";
|
|
426
|
+
case "go":
|
|
427
|
+
return "Go";
|
|
428
|
+
case "php":
|
|
429
|
+
return "PHP";
|
|
430
|
+
default:
|
|
431
|
+
return "your language";
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
async function readIf(cwd, name) {
|
|
435
|
+
try {
|
|
436
|
+
return await readFile3(`${cwd}/${name}`, "utf8");
|
|
437
|
+
} catch {
|
|
438
|
+
return "";
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
async function hasGemspec(cwd) {
|
|
442
|
+
try {
|
|
443
|
+
return (await readdir(cwd)).some((f) => f.endsWith(".gemspec"));
|
|
444
|
+
} catch {
|
|
445
|
+
return false;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
async function detectPythonFramework(cwd) {
|
|
449
|
+
const txt = (await readIf(cwd, "requirements.txt") + await readIf(cwd, "pyproject.toml") + await readIf(cwd, "Pipfile")).toLowerCase();
|
|
450
|
+
if (txt.includes("django")) return "Django";
|
|
451
|
+
if (txt.includes("fastapi")) return "FastAPI";
|
|
452
|
+
if (txt.includes("flask")) return "Flask";
|
|
453
|
+
return null;
|
|
454
|
+
}
|
|
455
|
+
async function detectRubyFramework(cwd) {
|
|
456
|
+
const txt = (await readIf(cwd, "Gemfile")).toLowerCase();
|
|
457
|
+
if (txt.includes("rails")) return "Rails";
|
|
458
|
+
if (txt.includes("sinatra")) return "Sinatra";
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
async function detectPhpFramework(cwd) {
|
|
462
|
+
const txt = (await readIf(cwd, "composer.json")).toLowerCase();
|
|
463
|
+
if (txt.includes("laravel")) return "Laravel";
|
|
464
|
+
if (txt.includes("symfony")) return "Symfony";
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
467
|
+
var SDK_PYPI = "letterapp";
|
|
468
|
+
var SDK_GEM = "letterapp";
|
|
469
|
+
function sdkInstall(cmd, args) {
|
|
470
|
+
return { cmd, args, display: `${cmd} ${args.join(" ")}` };
|
|
471
|
+
}
|
|
472
|
+
async function pythonSdkInstall(cwd) {
|
|
473
|
+
if (await fileExists(cwd, "uv.lock")) return sdkInstall("uv", ["add", SDK_PYPI]);
|
|
474
|
+
const pyproject = (await readIf(cwd, "pyproject.toml")).toLowerCase();
|
|
475
|
+
if (await fileExists(cwd, "poetry.lock") || pyproject.includes("[tool.poetry]")) {
|
|
476
|
+
return sdkInstall("poetry", ["add", SDK_PYPI]);
|
|
477
|
+
}
|
|
478
|
+
if (await fileExists(cwd, "Pipfile")) {
|
|
479
|
+
return sdkInstall("pipenv", ["install", SDK_PYPI]);
|
|
480
|
+
}
|
|
481
|
+
return sdkInstall("pip", ["install", SDK_PYPI]);
|
|
482
|
+
}
|
|
483
|
+
async function rubySdkInstall(cwd) {
|
|
484
|
+
if (await fileExists(cwd, "Gemfile")) {
|
|
485
|
+
return sdkInstall("bundle", ["add", SDK_GEM]);
|
|
486
|
+
}
|
|
487
|
+
return sdkInstall("gem", ["install", SDK_GEM]);
|
|
488
|
+
}
|
|
489
|
+
function runSdkInstall(install, cwd) {
|
|
490
|
+
return new Promise((resolve) => {
|
|
491
|
+
const child = spawn2(install.cmd, install.args, {
|
|
492
|
+
cwd,
|
|
493
|
+
stdio: "inherit",
|
|
494
|
+
shell: process.platform === "win32"
|
|
495
|
+
});
|
|
496
|
+
child.on("error", () => resolve(1));
|
|
497
|
+
child.on("close", (code) => resolve(code ?? 1));
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
function installCommand(pm, pkg) {
|
|
501
|
+
switch (pm) {
|
|
502
|
+
case "pnpm":
|
|
503
|
+
return `pnpm add ${pkg}`;
|
|
504
|
+
case "yarn":
|
|
505
|
+
return `yarn add ${pkg}`;
|
|
506
|
+
case "bun":
|
|
507
|
+
return `bun add ${pkg}`;
|
|
508
|
+
default:
|
|
509
|
+
return `npm install ${pkg}`;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
function runInstall(pm, pkg, cwd) {
|
|
513
|
+
const args = pm === "npm" ? ["install", pkg] : ["add", pkg];
|
|
514
|
+
return new Promise((resolve) => {
|
|
515
|
+
const child = spawn2(pm, args, {
|
|
516
|
+
cwd,
|
|
517
|
+
stdio: "inherit",
|
|
518
|
+
shell: process.platform === "win32"
|
|
519
|
+
});
|
|
520
|
+
child.on("error", () => resolve(1));
|
|
521
|
+
child.on("close", (code) => resolve(code ?? 1));
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// src/commands/login.ts
|
|
526
|
+
var SDK_PACKAGE = "@letterapp/node";
|
|
527
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
528
|
+
function envFileFor(stack) {
|
|
529
|
+
return stack.runtime === "node" ? ".env.local" : ".env";
|
|
530
|
+
}
|
|
531
|
+
function registerLoginCommand(program2) {
|
|
532
|
+
program2.command("login", { isDefault: true }).alias("init").description("Connect this project to Letter (interactive device login)").option("--no-open", "Don't open the browser; print the URL to open manually").option("-y, --yes", "Non-interactive: don't wait for Enter (agents/CI)").option("--no-install", "Skip installing the SDK").option("--base-url <url>", "Target a self-hosted or local Letter instance").option(
|
|
533
|
+
"--api-key <key>",
|
|
534
|
+
"CI only: write a key without the device flow (never use in chat)"
|
|
535
|
+
).action(async (opts) => {
|
|
536
|
+
const code = await runLogin(opts);
|
|
537
|
+
if (code !== 0) process.exitCode = code;
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
async function runLogin(opts) {
|
|
541
|
+
const base = getBaseUrl(opts.baseUrl);
|
|
542
|
+
const cwd = process.cwd();
|
|
543
|
+
const interactive = process.stdin.isTTY && !opts.yes;
|
|
544
|
+
banner();
|
|
545
|
+
if (opts.apiKey) {
|
|
546
|
+
const envFile = envFileFor(await detectStack(cwd));
|
|
547
|
+
const entries = { LETTER_API_KEY: opts.apiKey };
|
|
548
|
+
if (base !== DEFAULT_BASE_URL) entries.LETTER_BASE_URL = base;
|
|
549
|
+
const file = await upsertEnv(cwd, envFile, entries);
|
|
550
|
+
printSuccess(`Saved LETTER_API_KEY to ${rel(cwd, file)} (--api-key).`);
|
|
551
|
+
printWarning("--api-key is for CI. For interactive setup, run `letter login`.");
|
|
552
|
+
return 0;
|
|
553
|
+
}
|
|
554
|
+
let flow;
|
|
555
|
+
try {
|
|
556
|
+
flow = await startDeviceAuth(base);
|
|
557
|
+
} catch (err) {
|
|
558
|
+
printError(err);
|
|
559
|
+
return 1;
|
|
560
|
+
}
|
|
561
|
+
log(c.bold("Confirm this code in your browser:"));
|
|
562
|
+
log();
|
|
563
|
+
log(" " + c.cyan(c.bold(flow.user_code)));
|
|
564
|
+
log();
|
|
565
|
+
if (interactive && opts.open) {
|
|
566
|
+
await prompt(c.dim("Press Enter to open your browser\u2026 "));
|
|
567
|
+
}
|
|
568
|
+
if (opts.open) openUrl(flow.verification_uri_complete);
|
|
569
|
+
printInfo("If your browser didn't open, visit:");
|
|
570
|
+
log(" " + c.blue(flow.verification_uri_complete));
|
|
571
|
+
log();
|
|
572
|
+
printInfo("Waiting for you to approve\u2026 (Ctrl+C to cancel)");
|
|
573
|
+
const deadline = Date.now() + flow.expires_in * 1e3;
|
|
574
|
+
let intervalMs = Math.max(1, flow.interval) * 1e3;
|
|
575
|
+
while (Date.now() < deadline) {
|
|
576
|
+
await sleep(intervalMs);
|
|
577
|
+
let res;
|
|
578
|
+
try {
|
|
579
|
+
res = await pollDeviceAuth(base, flow.device_code);
|
|
580
|
+
} catch (err) {
|
|
581
|
+
printError(err);
|
|
582
|
+
return 1;
|
|
583
|
+
}
|
|
584
|
+
if (res.status === "authorization_pending") continue;
|
|
585
|
+
if (res.status === "slow_down") {
|
|
586
|
+
intervalMs += 1e3;
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
if (res.status === "access_denied") {
|
|
590
|
+
printError(new Error("Request denied in the browser. Nothing was changed."));
|
|
591
|
+
return 1;
|
|
592
|
+
}
|
|
593
|
+
if (res.status === "expired_token") {
|
|
594
|
+
printError(new Error("This login expired. Run the command again to retry."));
|
|
595
|
+
return 1;
|
|
596
|
+
}
|
|
597
|
+
return finish(res, cwd, opts.install);
|
|
598
|
+
}
|
|
599
|
+
printError(new Error("Timed out waiting for approval. Run the command again."));
|
|
600
|
+
return 1;
|
|
601
|
+
}
|
|
602
|
+
async function finish(approved, cwd, doInstall) {
|
|
603
|
+
const { api_key: apiKey, pat, base_url: baseUrl, project, workspace } = approved;
|
|
604
|
+
log();
|
|
605
|
+
printSuccess(`Approved for project ${c.bold(project.name)}.`);
|
|
606
|
+
const stack = await detectStack(cwd);
|
|
607
|
+
const isNode = stack.runtime === "node";
|
|
608
|
+
const envFile = envFileFor(stack);
|
|
609
|
+
const entries = { LETTER_API_KEY: apiKey };
|
|
610
|
+
if (baseUrl && baseUrl !== DEFAULT_BASE_URL) entries.LETTER_BASE_URL = baseUrl;
|
|
611
|
+
const written = await upsertEnv(cwd, envFile, entries);
|
|
612
|
+
printSuccess(`Saved LETTER_API_KEY to ${rel(cwd, written)}.`);
|
|
613
|
+
try {
|
|
614
|
+
const credFile = await saveCredential({
|
|
615
|
+
apiKey,
|
|
616
|
+
pat,
|
|
617
|
+
baseUrl: baseUrl || DEFAULT_BASE_URL,
|
|
618
|
+
project,
|
|
619
|
+
workspace,
|
|
620
|
+
savedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
621
|
+
});
|
|
622
|
+
printSuccess(`Stored credentials in ${tildify(credFile)} for tooling (MCP + CLI).`);
|
|
623
|
+
} catch {
|
|
624
|
+
printWarning("Could not write ~/.letter/credentials.json (continuing).");
|
|
625
|
+
}
|
|
626
|
+
if (isNode) {
|
|
627
|
+
const pm = await detectPackageManager(cwd);
|
|
628
|
+
if (doInstall) {
|
|
629
|
+
printInfo(`Installing ${SDK_PACKAGE} with ${pm}\u2026`);
|
|
630
|
+
const code = await runInstall(pm, SDK_PACKAGE, cwd);
|
|
631
|
+
if (code === 0) printSuccess(`Installed ${SDK_PACKAGE}.`);
|
|
632
|
+
else printWarning(`Install failed. Run: ${installCommand(pm, SDK_PACKAGE)}`);
|
|
633
|
+
} else {
|
|
634
|
+
printInfo(`Skipped install. Run: ${installCommand(pm, SDK_PACKAGE)}`);
|
|
635
|
+
}
|
|
636
|
+
printNodeInstructions(stack.framework, envFile);
|
|
637
|
+
} else if (stack.runtime === "python" || stack.runtime === "ruby") {
|
|
638
|
+
const install = stack.runtime === "python" ? await pythonSdkInstall(cwd) : await rubySdkInstall(cwd);
|
|
639
|
+
await installSdk(install, doInstall);
|
|
640
|
+
if (stack.runtime === "python") {
|
|
641
|
+
printPythonInstructions(stack.framework, envFile);
|
|
642
|
+
} else {
|
|
643
|
+
printRubyInstructions(stack.framework, envFile);
|
|
644
|
+
}
|
|
645
|
+
} else {
|
|
646
|
+
printInfo(
|
|
647
|
+
`Detected ${stackLabel(stack)}. No ${languageName(stack)} SDK yet, so use the HTTP API - no package installed.`
|
|
648
|
+
);
|
|
649
|
+
printHttpInstructions(stack, envFile, baseUrl || DEFAULT_BASE_URL);
|
|
650
|
+
}
|
|
651
|
+
return 0;
|
|
652
|
+
}
|
|
653
|
+
async function installSdk(install, doInstall) {
|
|
654
|
+
if (doInstall) {
|
|
655
|
+
printInfo(`Installing letterapp (${install.display})\u2026`);
|
|
656
|
+
const code = await runSdkInstall(install, process.cwd());
|
|
657
|
+
if (code === 0) printSuccess("Installed letterapp.");
|
|
658
|
+
else printWarning(`Install failed. Run: ${install.display}`);
|
|
659
|
+
} else {
|
|
660
|
+
printInfo(`Skipped install. Run: ${install.display}`);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
function printNodeInstructions(framework, envFile) {
|
|
664
|
+
log();
|
|
665
|
+
log(c.bold("Next: hand these steps to your coding agent"));
|
|
666
|
+
log(
|
|
667
|
+
c.dim(
|
|
668
|
+
framework ? `Detected ${framework}. The SDK is installed and LETTER_API_KEY is in ${envFile}.` : `The SDK is installed and LETTER_API_KEY is in ${envFile}.`
|
|
669
|
+
)
|
|
670
|
+
);
|
|
671
|
+
log();
|
|
672
|
+
log(
|
|
673
|
+
c.bold(" 1. ") + `Create a server-side client (e.g. ${c.cyan("lib/letter.ts")}):`
|
|
674
|
+
);
|
|
675
|
+
log(c.dim(' import { Letter } from "@letterapp/node";'));
|
|
676
|
+
log(c.dim(" export const letter = new Letter({"));
|
|
677
|
+
log(c.dim(" apiKey: process.env.LETTER_API_KEY!,"));
|
|
678
|
+
log(
|
|
679
|
+
c.dim(
|
|
680
|
+
" ...(process.env.LETTER_BASE_URL ? { baseUrl: process.env.LETTER_BASE_URL } : {}),"
|
|
681
|
+
)
|
|
682
|
+
);
|
|
683
|
+
log(c.dim(" });"));
|
|
684
|
+
log();
|
|
685
|
+
log(
|
|
686
|
+
c.bold(" 2. ") + `Call ${c.cyan("identify()")} where users sign up or log in:`
|
|
687
|
+
);
|
|
688
|
+
log(
|
|
689
|
+
c.dim(
|
|
690
|
+
" await letter.identify({ userId: user.id, email: user.email });"
|
|
691
|
+
)
|
|
692
|
+
);
|
|
693
|
+
log();
|
|
694
|
+
log(
|
|
695
|
+
c.bold(" 3. ") + `Call ${c.cyan("track()")} on your 2-3 most important actions:`
|
|
696
|
+
);
|
|
697
|
+
log(
|
|
698
|
+
c.dim(' await letter.track({ userId: user.id, event: "Signed Up" });')
|
|
699
|
+
);
|
|
700
|
+
log();
|
|
701
|
+
log(
|
|
702
|
+
c.dim(
|
|
703
|
+
" In serverless handlers or scripts, await letter.flush() before returning."
|
|
704
|
+
)
|
|
705
|
+
);
|
|
706
|
+
log();
|
|
707
|
+
log(c.dim("Verify it landed: ") + c.bold("letter status"));
|
|
708
|
+
log(
|
|
709
|
+
c.dim(
|
|
710
|
+
`Your API key is in ${envFile} - keep it out of source control, and don't echo its value.`
|
|
711
|
+
)
|
|
712
|
+
);
|
|
713
|
+
log();
|
|
714
|
+
}
|
|
715
|
+
function printPythonInstructions(framework, envFile) {
|
|
716
|
+
log();
|
|
717
|
+
log(c.bold("Next: hand these steps to your coding agent"));
|
|
718
|
+
log(
|
|
719
|
+
c.dim(
|
|
720
|
+
framework ? `Detected ${framework}. The SDK is installed and LETTER_API_KEY is in ${envFile}.` : `The SDK is installed and LETTER_API_KEY is in ${envFile}.`
|
|
721
|
+
)
|
|
722
|
+
);
|
|
723
|
+
log();
|
|
724
|
+
log(
|
|
725
|
+
c.bold(" 1. ") + `Create a shared client (e.g. ${c.cyan("letter.py")}):`
|
|
726
|
+
);
|
|
727
|
+
log(c.dim(" import os"));
|
|
728
|
+
log(c.dim(" from letterapp import Letter"));
|
|
729
|
+
log(c.dim(' letter = Letter(api_key=os.environ["LETTER_API_KEY"])'));
|
|
730
|
+
log(c.dim(' # Self-hosted/local: pass base_url=os.environ["LETTER_BASE_URL"].'));
|
|
731
|
+
log();
|
|
732
|
+
log(
|
|
733
|
+
c.bold(" 2. ") + `Call ${c.cyan("identify()")} where users sign up or log in:`
|
|
734
|
+
);
|
|
735
|
+
log(c.dim(" letter.identify(user_id=user.id, email=user.email)"));
|
|
736
|
+
log();
|
|
737
|
+
log(
|
|
738
|
+
c.bold(" 3. ") + `Call ${c.cyan("track()")} on your 2-3 most important actions:`
|
|
739
|
+
);
|
|
740
|
+
log(c.dim(' letter.track(user_id=user.id, event="Signed Up")'));
|
|
741
|
+
log();
|
|
742
|
+
log(
|
|
743
|
+
c.dim(
|
|
744
|
+
" In serverless handlers or scripts, call letter.flush() (or letter.close()) before returning."
|
|
745
|
+
)
|
|
746
|
+
);
|
|
747
|
+
log();
|
|
748
|
+
log(c.dim("Verify it landed: ") + c.bold("letter status"));
|
|
749
|
+
log(
|
|
750
|
+
c.dim(
|
|
751
|
+
`Your API key is in ${envFile} - keep it out of source control, and don't echo its value.`
|
|
752
|
+
)
|
|
753
|
+
);
|
|
754
|
+
log();
|
|
755
|
+
}
|
|
756
|
+
function printRubyInstructions(framework, envFile) {
|
|
757
|
+
log();
|
|
758
|
+
log(c.bold("Next: hand these steps to your coding agent"));
|
|
759
|
+
log(
|
|
760
|
+
c.dim(
|
|
761
|
+
framework ? `Detected ${framework}. The SDK is installed and LETTER_API_KEY is in ${envFile}.` : `The SDK is installed and LETTER_API_KEY is in ${envFile}.`
|
|
762
|
+
)
|
|
763
|
+
);
|
|
764
|
+
log();
|
|
765
|
+
log(
|
|
766
|
+
c.bold(" 1. ") + `Create a shared client (in Rails, ${c.cyan("config/initializers/letter.rb")}):`
|
|
767
|
+
);
|
|
768
|
+
log(c.dim(' require "letterapp"'));
|
|
769
|
+
log(c.dim(' LETTER = Letterapp::Client.new(api_key: ENV["LETTER_API_KEY"])'));
|
|
770
|
+
log(c.dim(' # Self-hosted/local: pass base_url: ENV["LETTER_BASE_URL"].'));
|
|
771
|
+
log();
|
|
772
|
+
log(
|
|
773
|
+
c.bold(" 2. ") + `Call ${c.cyan("identify")} where users sign up or log in:`
|
|
774
|
+
);
|
|
775
|
+
log(c.dim(" LETTER.identify(user_id: user.id, email: user.email)"));
|
|
776
|
+
log();
|
|
777
|
+
log(
|
|
778
|
+
c.bold(" 3. ") + `Call ${c.cyan("track")} on your 2-3 most important actions:`
|
|
779
|
+
);
|
|
780
|
+
log(c.dim(' LETTER.track(user_id: user.id, event: "Signed Up")'));
|
|
781
|
+
log();
|
|
782
|
+
log(
|
|
783
|
+
c.dim(
|
|
784
|
+
" In serverless handlers or scripts, call LETTER.flush before returning."
|
|
785
|
+
)
|
|
786
|
+
);
|
|
787
|
+
log();
|
|
788
|
+
log(c.dim("Verify it landed: ") + c.bold("letter status"));
|
|
789
|
+
log(
|
|
790
|
+
c.dim(
|
|
791
|
+
`Your API key is in ${envFile} - keep it out of source control, and don't echo its value.`
|
|
792
|
+
)
|
|
793
|
+
);
|
|
794
|
+
log();
|
|
795
|
+
}
|
|
796
|
+
function printHttpInstructions(stack, envFile, base) {
|
|
797
|
+
log();
|
|
798
|
+
log(c.bold("Next: hand these steps to your coding agent"));
|
|
799
|
+
log(
|
|
800
|
+
c.dim(
|
|
801
|
+
`Detected ${stackLabel(stack)}. There's no ${languageName(stack)} SDK yet, so call Letter's HTTP API directly in ${languageName(stack)} with your usual HTTP client.`
|
|
802
|
+
)
|
|
803
|
+
);
|
|
804
|
+
log();
|
|
805
|
+
log(
|
|
806
|
+
c.dim(
|
|
807
|
+
`LETTER_API_KEY is in ${envFile}. Read it from the environment; never inline it.`
|
|
808
|
+
)
|
|
809
|
+
);
|
|
810
|
+
log();
|
|
811
|
+
log(c.bold(" 1. ") + "identify users where they sign up or log in:");
|
|
812
|
+
log(c.dim(` POST ${base}/v1/identify`));
|
|
813
|
+
log(c.dim(" Authorization: Bearer $LETTER_API_KEY"));
|
|
814
|
+
log(c.dim(' { "userId": "<your user id>", "email": "<email>" }'));
|
|
815
|
+
log();
|
|
816
|
+
log(c.bold(" 2. ") + "track your 2-3 most important actions:");
|
|
817
|
+
log(c.dim(` POST ${base}/v1/track`));
|
|
818
|
+
log(c.dim(" Authorization: Bearer $LETTER_API_KEY"));
|
|
819
|
+
log(c.dim(' { "userId": "<your user id>", "event": "Signed Up" }'));
|
|
820
|
+
log();
|
|
821
|
+
log(
|
|
822
|
+
c.dim(
|
|
823
|
+
" Send Content-Type: application/json. The same auth/shape covers group and batch."
|
|
824
|
+
)
|
|
825
|
+
);
|
|
826
|
+
log();
|
|
827
|
+
log(c.dim("Verify it landed: ") + c.bold("letter status"));
|
|
828
|
+
log(
|
|
829
|
+
c.dim(
|
|
830
|
+
`Your API key is in ${envFile} - keep it out of source control, and don't echo its value.`
|
|
831
|
+
)
|
|
832
|
+
);
|
|
833
|
+
log();
|
|
834
|
+
}
|
|
835
|
+
function rel(cwd, file) {
|
|
836
|
+
return file.startsWith(cwd) ? file.slice(cwd.length + 1) || file : file;
|
|
837
|
+
}
|
|
838
|
+
function tildify(file) {
|
|
839
|
+
const home = process.env.HOME || process.env.USERPROFILE;
|
|
840
|
+
return home && file.startsWith(home) ? `~${file.slice(home.length)}` : file;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// src/commands/auth.ts
|
|
844
|
+
function mask(key) {
|
|
845
|
+
return key.length > 12 ? key.slice(0, 8) + "\u2026" + key.slice(-4) : "set";
|
|
846
|
+
}
|
|
847
|
+
function registerAuthCommands(program2) {
|
|
848
|
+
const auth = program2.command("auth").description("Manage the stored Letter connection");
|
|
849
|
+
auth.command("status").description("Show whether this machine is connected to Letter").action(async () => {
|
|
850
|
+
const cred = await readCredential();
|
|
851
|
+
const envKey = process.env.LETTER_API_KEY;
|
|
852
|
+
if (!cred && !envKey) {
|
|
853
|
+
if (isJsonMode()) printJson({ connected: false });
|
|
854
|
+
else printInfo("Not connected. Run " + c.bold("letter login") + " to set up.");
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
if (isJsonMode()) {
|
|
858
|
+
printJson({
|
|
859
|
+
connected: true,
|
|
860
|
+
source: envKey ? "env" : "credentials",
|
|
861
|
+
project: cred?.project ?? null,
|
|
862
|
+
base_url: cred?.baseUrl ?? null,
|
|
863
|
+
key: envKey ? "env" : cred ? mask(cred.apiKey) : null
|
|
864
|
+
});
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
printSuccess("Connected" + (cred ? ` to ${c.bold(cred.project.name)}` : ""));
|
|
868
|
+
if (cred) {
|
|
869
|
+
console.log(c.dim(" Project: ") + cred.project.slug);
|
|
870
|
+
console.log(c.dim(" Key: ") + mask(cred.apiKey));
|
|
871
|
+
console.log(c.dim(" API: ") + cred.baseUrl);
|
|
872
|
+
}
|
|
873
|
+
if (envKey) console.log(c.dim(" LETTER_API_KEY is set in the environment."));
|
|
874
|
+
});
|
|
875
|
+
auth.command("logout").description("Remove the stored credential (~/.letter/credentials.json)").action(async () => {
|
|
876
|
+
await clearCredential();
|
|
877
|
+
printSuccess("Removed stored credential.");
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// src/commands/status.ts
|
|
882
|
+
function registerStatusCommand(program2) {
|
|
883
|
+
program2.command("status").description("Check whether your project has received any contacts or events").action(async () => {
|
|
884
|
+
const spin = spinner("Checking your Letter project\u2026").start();
|
|
885
|
+
try {
|
|
886
|
+
const { data } = await client.get("/v1/status");
|
|
887
|
+
spin.stop();
|
|
888
|
+
if (isJsonMode()) {
|
|
889
|
+
printJson(data);
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
const cred = await readCredential();
|
|
893
|
+
const contacts = data.contacts ?? 0;
|
|
894
|
+
const events = data.events ?? 0;
|
|
895
|
+
if (cred) printInfo(`Project ${c.bold(cred.project.name)}`);
|
|
896
|
+
if (contacts > 0 || events > 0) {
|
|
897
|
+
printSuccess(`Connected. ${contacts} contact(s), ${events} event(s) received.`);
|
|
898
|
+
} else {
|
|
899
|
+
printInfo("Connected, but no data yet. Fire your first identify/track call.");
|
|
900
|
+
}
|
|
901
|
+
} catch (err) {
|
|
902
|
+
spin.stop();
|
|
903
|
+
printError(err);
|
|
904
|
+
process.exitCode = 1;
|
|
905
|
+
}
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// src/commands/config.ts
|
|
910
|
+
function registerConfigCommands(program2) {
|
|
911
|
+
const cfg = program2.command("config").description("Manage CLI configuration");
|
|
912
|
+
cfg.command("set").description("Set a config value").argument("<key>", "Config key: base-url").argument("<value>", "Value to set").action((key, value) => {
|
|
913
|
+
switch (key) {
|
|
914
|
+
case "base-url":
|
|
915
|
+
setBaseUrl(value);
|
|
916
|
+
printSuccess(`Base URL set to ${getBaseUrl()}`);
|
|
917
|
+
break;
|
|
918
|
+
default:
|
|
919
|
+
printError(new Error(`Unknown config key: ${key}. Valid keys: base-url`));
|
|
920
|
+
process.exitCode = 1;
|
|
921
|
+
}
|
|
922
|
+
});
|
|
923
|
+
cfg.command("get").description("Show CLI configuration").argument("[key]", "Config key (omit to show all)").action((key) => {
|
|
924
|
+
const all = { base_url: getBaseUrl(), config_path: getConfigPath() };
|
|
925
|
+
if (key && key !== "base-url" && key !== "path") {
|
|
926
|
+
printError(new Error(`Unknown config key: ${key}`));
|
|
927
|
+
process.exitCode = 1;
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
if (isJsonMode()) {
|
|
931
|
+
printJson(key === "base-url" ? { base_url: all.base_url } : key === "path" ? { config_path: all.config_path } : all);
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
if (key === "base-url") console.log(all.base_url);
|
|
935
|
+
else if (key === "path") console.log(all.config_path);
|
|
936
|
+
else for (const [k, v] of Object.entries(all)) console.log(c.bold(k + ":") + " " + v);
|
|
937
|
+
});
|
|
938
|
+
cfg.command("reset").description("Reset CLI configuration to defaults").action(() => {
|
|
939
|
+
resetConfig();
|
|
940
|
+
printSuccess("Configuration reset.");
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// src/commands/resources.ts
|
|
945
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
946
|
+
async function readValue(raw) {
|
|
947
|
+
if (raw.startsWith("@")) return (await readFile4(raw.slice(1), "utf8")).trim();
|
|
948
|
+
return raw;
|
|
949
|
+
}
|
|
950
|
+
async function buildBody(fields, opts) {
|
|
951
|
+
const body = {};
|
|
952
|
+
for (const f of fields) {
|
|
953
|
+
const v = opts[camel(f.key)];
|
|
954
|
+
if (v === void 0) continue;
|
|
955
|
+
if (f.json) {
|
|
956
|
+
body[f.key] = JSON.parse(await readValue(String(v)));
|
|
957
|
+
} else if (f.number) {
|
|
958
|
+
body[f.key] = Number(v);
|
|
959
|
+
} else if (f.boolean) {
|
|
960
|
+
body[f.key] = v === true || v === "true";
|
|
961
|
+
} else {
|
|
962
|
+
body[f.key] = await readValue(String(v));
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
return body;
|
|
966
|
+
}
|
|
967
|
+
async function buildForm(fields, opts) {
|
|
968
|
+
const form = new FormData();
|
|
969
|
+
for (const f of fields) {
|
|
970
|
+
const v = opts[camel(f.key)];
|
|
971
|
+
if (v === void 0) continue;
|
|
972
|
+
if (f.file) {
|
|
973
|
+
const path3 = String(v);
|
|
974
|
+
const bytes = await readFile4(path3);
|
|
975
|
+
const name = path3.split("/").pop() || f.key;
|
|
976
|
+
form.append(f.key, new Blob([new Uint8Array(bytes)]), name);
|
|
977
|
+
} else if (f.json) {
|
|
978
|
+
form.append(f.key, await readValue(String(v)));
|
|
979
|
+
} else {
|
|
980
|
+
form.append(f.key, String(v));
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
return form;
|
|
984
|
+
}
|
|
985
|
+
function buildQuery(fields, opts) {
|
|
986
|
+
const q = {};
|
|
987
|
+
for (const f of fields) {
|
|
988
|
+
const v = opts[camel(f.key)];
|
|
989
|
+
if (v === void 0) continue;
|
|
990
|
+
q[f.key] = f.number ? Number(v) : String(v);
|
|
991
|
+
}
|
|
992
|
+
return q;
|
|
993
|
+
}
|
|
994
|
+
function camel(key) {
|
|
995
|
+
return key.replace(/[-_]([a-z])/g, (_, ch) => ch.toUpperCase());
|
|
996
|
+
}
|
|
997
|
+
function applyFields(cmd, fields) {
|
|
998
|
+
for (const f of fields) {
|
|
999
|
+
cmd.option(f.flag, f.description ?? f.key);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
async function basePath(spec, project) {
|
|
1003
|
+
const seg = spec.segment ?? spec.name;
|
|
1004
|
+
if (!spec.scoped) return `/v1/${seg}`;
|
|
1005
|
+
const slug = await resolveProjectSlug(project);
|
|
1006
|
+
return `/v1/projects/${slug}/${seg}`;
|
|
1007
|
+
}
|
|
1008
|
+
function withProject(cmd, scoped) {
|
|
1009
|
+
if (scoped) {
|
|
1010
|
+
cmd.option("-p, --project <slug>", "Project slug (default: connected project)");
|
|
1011
|
+
}
|
|
1012
|
+
return cmd;
|
|
1013
|
+
}
|
|
1014
|
+
function fail(err) {
|
|
1015
|
+
printError(err);
|
|
1016
|
+
process.exitCode = 1;
|
|
1017
|
+
}
|
|
1018
|
+
function register(program2, spec) {
|
|
1019
|
+
const group = program2.command(spec.name).description(spec.describe);
|
|
1020
|
+
if (spec.alias) group.alias(spec.alias);
|
|
1021
|
+
const idName = spec.idName ?? "id";
|
|
1022
|
+
if (spec.list) {
|
|
1023
|
+
const cmd = withProject(
|
|
1024
|
+
group.command("list").description(`List ${spec.name}`),
|
|
1025
|
+
spec.scoped
|
|
1026
|
+
);
|
|
1027
|
+
if (spec.list.paginated) {
|
|
1028
|
+
cmd.option("--limit <n>", "Max items (1-100)");
|
|
1029
|
+
cmd.option("--cursor <cursor>", "Pagination cursor");
|
|
1030
|
+
}
|
|
1031
|
+
if (spec.list.query) applyFields(cmd, spec.list.query);
|
|
1032
|
+
cmd.action(async (opts) => {
|
|
1033
|
+
try {
|
|
1034
|
+
const query = {};
|
|
1035
|
+
if (spec.list?.paginated) {
|
|
1036
|
+
if (opts.limit) query.limit = Number(opts.limit);
|
|
1037
|
+
if (opts.cursor) query.cursor = opts.cursor;
|
|
1038
|
+
}
|
|
1039
|
+
if (spec.list?.query) Object.assign(query, buildQuery(spec.list.query, opts));
|
|
1040
|
+
const { data } = await mgmt.get(await basePath(spec, opts.project), query);
|
|
1041
|
+
emit(data);
|
|
1042
|
+
} catch (err) {
|
|
1043
|
+
fail(err);
|
|
1044
|
+
}
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
1047
|
+
if (spec.get) {
|
|
1048
|
+
const cmd = withProject(
|
|
1049
|
+
group.command("get").description(`Get a ${spec.name} by ${idName}`).argument(`<${idName}>`, idName),
|
|
1050
|
+
spec.scoped
|
|
1051
|
+
);
|
|
1052
|
+
cmd.action(async (idValue, opts) => {
|
|
1053
|
+
try {
|
|
1054
|
+
const path3 = `${await basePath(spec, opts.project)}/${encodeURIComponent(idValue)}`;
|
|
1055
|
+
const { data } = await mgmt.get(path3);
|
|
1056
|
+
emit(data);
|
|
1057
|
+
} catch (err) {
|
|
1058
|
+
fail(err);
|
|
1059
|
+
}
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
1062
|
+
if (spec.create) {
|
|
1063
|
+
const cmd = withProject(
|
|
1064
|
+
group.command("create").description(`Create a ${spec.name}`),
|
|
1065
|
+
spec.scoped
|
|
1066
|
+
);
|
|
1067
|
+
applyFields(cmd, spec.create);
|
|
1068
|
+
cmd.action(async (opts) => {
|
|
1069
|
+
try {
|
|
1070
|
+
const body = await buildBody(spec.create, opts);
|
|
1071
|
+
const { data } = await mgmt.post(await basePath(spec, opts.project), body);
|
|
1072
|
+
emit(data);
|
|
1073
|
+
} catch (err) {
|
|
1074
|
+
fail(err);
|
|
1075
|
+
}
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
if (spec.update) {
|
|
1079
|
+
const cmd = withProject(
|
|
1080
|
+
group.command("update").description(`Update a ${spec.name}`).argument(`<${idName}>`, idName),
|
|
1081
|
+
spec.scoped
|
|
1082
|
+
);
|
|
1083
|
+
applyFields(cmd, spec.update);
|
|
1084
|
+
cmd.action(async (idValue, opts) => {
|
|
1085
|
+
try {
|
|
1086
|
+
const body = await buildBody(spec.update, opts);
|
|
1087
|
+
const path3 = `${await basePath(spec, opts.project)}/${encodeURIComponent(idValue)}`;
|
|
1088
|
+
const { data } = await mgmt.patch(path3, body);
|
|
1089
|
+
emit(data);
|
|
1090
|
+
} catch (err) {
|
|
1091
|
+
fail(err);
|
|
1092
|
+
}
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
if (spec.remove) {
|
|
1096
|
+
const cmd = withProject(
|
|
1097
|
+
group.command("delete").description(`Delete a ${spec.name}`).argument(`<${idName}>`, idName),
|
|
1098
|
+
spec.scoped
|
|
1099
|
+
);
|
|
1100
|
+
cmd.action(async (idValue, opts) => {
|
|
1101
|
+
try {
|
|
1102
|
+
const path3 = `${await basePath(spec, opts.project)}/${encodeURIComponent(idValue)}`;
|
|
1103
|
+
await mgmt.delete(path3);
|
|
1104
|
+
printSuccess(`Deleted ${spec.name.replace(/s$/, "")} ${idValue}.`);
|
|
1105
|
+
} catch (err) {
|
|
1106
|
+
fail(err);
|
|
1107
|
+
}
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
1110
|
+
for (const action of spec.actions ?? []) {
|
|
1111
|
+
const needsId = action.needsId ?? true;
|
|
1112
|
+
const cmd = withProject(
|
|
1113
|
+
needsId ? group.command(action.name).description(action.describe).argument(`<${idName}>`, idName) : group.command(action.name).description(action.describe),
|
|
1114
|
+
spec.scoped
|
|
1115
|
+
);
|
|
1116
|
+
if (action.fields) applyFields(cmd, action.fields);
|
|
1117
|
+
if (action.query) applyFields(cmd, action.query);
|
|
1118
|
+
const handler = async (...args) => {
|
|
1119
|
+
const opts = args[args.length - 2];
|
|
1120
|
+
const idValue = needsId ? args[0] : void 0;
|
|
1121
|
+
try {
|
|
1122
|
+
let path3 = await basePath(spec, opts.project);
|
|
1123
|
+
if (needsId) path3 += `/${encodeURIComponent(idValue)}`;
|
|
1124
|
+
path3 += action.suffix;
|
|
1125
|
+
if (action.method === "get") {
|
|
1126
|
+
const query = action.query ? buildQuery(action.query, opts) : void 0;
|
|
1127
|
+
const { data: data2 } = await mgmt.get(path3, query);
|
|
1128
|
+
emit(data2);
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
if (action.multipart && action.fields) {
|
|
1132
|
+
const form = await buildForm(action.fields, opts);
|
|
1133
|
+
const { data: data2, status: status2 } = await mgmt.request(
|
|
1134
|
+
action.method.toUpperCase(),
|
|
1135
|
+
path3,
|
|
1136
|
+
{ form }
|
|
1137
|
+
);
|
|
1138
|
+
if (status2 === 204) printSuccess(`${action.name} ok.`);
|
|
1139
|
+
else emit(data2);
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
const body = action.fields ? await buildBody(action.fields, opts) : void 0;
|
|
1143
|
+
const { data, status } = await mgmt.request(
|
|
1144
|
+
action.method.toUpperCase(),
|
|
1145
|
+
path3,
|
|
1146
|
+
{ body }
|
|
1147
|
+
);
|
|
1148
|
+
if (status === 204) printSuccess(`${action.name} ok.`);
|
|
1149
|
+
else emit(data);
|
|
1150
|
+
} catch (err) {
|
|
1151
|
+
fail(err);
|
|
1152
|
+
}
|
|
1153
|
+
};
|
|
1154
|
+
cmd.action(handler);
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
var cursor = { paginated: true };
|
|
1158
|
+
var SPECS = [
|
|
1159
|
+
// -- Workspace ------------------------------------------------------------
|
|
1160
|
+
{
|
|
1161
|
+
name: "projects",
|
|
1162
|
+
describe: "Manage projects",
|
|
1163
|
+
scoped: false,
|
|
1164
|
+
idName: "slug",
|
|
1165
|
+
list: {},
|
|
1166
|
+
get: true,
|
|
1167
|
+
create: [
|
|
1168
|
+
{ flag: "--name <name>", key: "name" },
|
|
1169
|
+
{ flag: "--timezone <tz>", key: "timezone" }
|
|
1170
|
+
],
|
|
1171
|
+
update: [
|
|
1172
|
+
{ flag: "--name <name>", key: "name" },
|
|
1173
|
+
{ flag: "--timezone <tz>", key: "timezone" }
|
|
1174
|
+
],
|
|
1175
|
+
remove: true
|
|
1176
|
+
},
|
|
1177
|
+
{
|
|
1178
|
+
name: "members",
|
|
1179
|
+
describe: "Manage workspace members",
|
|
1180
|
+
scoped: false,
|
|
1181
|
+
idName: "userId",
|
|
1182
|
+
list: {},
|
|
1183
|
+
create: [
|
|
1184
|
+
{ flag: "--email <email>", key: "email" },
|
|
1185
|
+
{ flag: "--role <role>", key: "role", description: "member | admin" }
|
|
1186
|
+
],
|
|
1187
|
+
remove: true
|
|
1188
|
+
},
|
|
1189
|
+
{
|
|
1190
|
+
name: "invitations",
|
|
1191
|
+
describe: "Manage workspace invitations",
|
|
1192
|
+
scoped: false,
|
|
1193
|
+
list: {},
|
|
1194
|
+
create: [
|
|
1195
|
+
{ flag: "--email <email>", key: "email" },
|
|
1196
|
+
{ flag: "--role <role>", key: "role", description: "member | admin" }
|
|
1197
|
+
],
|
|
1198
|
+
remove: true
|
|
1199
|
+
},
|
|
1200
|
+
{
|
|
1201
|
+
// Personal access tokens (lt_pat_*) authenticate the CLI / Management API.
|
|
1202
|
+
// The dashboard calls these "API keys"; `tokens` stays as a hidden alias.
|
|
1203
|
+
name: "api-keys",
|
|
1204
|
+
alias: "tokens",
|
|
1205
|
+
segment: "tokens",
|
|
1206
|
+
describe: "Manage your personal access tokens (lt_pat_*, the CLI/API credential)",
|
|
1207
|
+
scoped: false,
|
|
1208
|
+
list: {},
|
|
1209
|
+
create: [
|
|
1210
|
+
{ flag: "--name <name>", key: "name" },
|
|
1211
|
+
{
|
|
1212
|
+
flag: "--expires-at <iso>",
|
|
1213
|
+
key: "expires_at",
|
|
1214
|
+
description: "Expiry as an ISO 8601 datetime (default: never)"
|
|
1215
|
+
}
|
|
1216
|
+
],
|
|
1217
|
+
remove: true
|
|
1218
|
+
},
|
|
1219
|
+
// -- Contacts & data ------------------------------------------------------
|
|
1220
|
+
{
|
|
1221
|
+
name: "contacts",
|
|
1222
|
+
describe: "Manage contacts",
|
|
1223
|
+
scoped: true,
|
|
1224
|
+
idName: "externalId",
|
|
1225
|
+
list: { ...cursor },
|
|
1226
|
+
get: true,
|
|
1227
|
+
actions: [
|
|
1228
|
+
{
|
|
1229
|
+
name: "suppress",
|
|
1230
|
+
describe: "Suppress a contact",
|
|
1231
|
+
method: "post",
|
|
1232
|
+
suffix: "/suppress"
|
|
1233
|
+
},
|
|
1234
|
+
{
|
|
1235
|
+
name: "resubscribe",
|
|
1236
|
+
describe: "Resubscribe a contact",
|
|
1237
|
+
method: "post",
|
|
1238
|
+
suffix: "/resubscribe"
|
|
1239
|
+
},
|
|
1240
|
+
{
|
|
1241
|
+
name: "import",
|
|
1242
|
+
describe: "Upload a CSV import (--file <path> --mapping <json|@file>)",
|
|
1243
|
+
method: "post",
|
|
1244
|
+
needsId: false,
|
|
1245
|
+
suffix: "/imports",
|
|
1246
|
+
multipart: true,
|
|
1247
|
+
fields: [
|
|
1248
|
+
{ flag: "--file <path>", key: "file", file: true },
|
|
1249
|
+
{ flag: "--mapping <json>", key: "mapping", json: true },
|
|
1250
|
+
{ flag: "--dedupe <strategy>", key: "dedupe", description: "update | skip" },
|
|
1251
|
+
{ flag: "--row-count <n>", key: "rowCount" }
|
|
1252
|
+
]
|
|
1253
|
+
}
|
|
1254
|
+
]
|
|
1255
|
+
},
|
|
1256
|
+
{
|
|
1257
|
+
name: "accounts",
|
|
1258
|
+
describe: "Inspect accounts",
|
|
1259
|
+
scoped: true,
|
|
1260
|
+
idName: "externalId",
|
|
1261
|
+
list: { ...cursor },
|
|
1262
|
+
get: true
|
|
1263
|
+
},
|
|
1264
|
+
{
|
|
1265
|
+
name: "events",
|
|
1266
|
+
describe: "List events",
|
|
1267
|
+
scoped: true,
|
|
1268
|
+
list: { ...cursor, query: [{ flag: "--name <name>", key: "name" }] }
|
|
1269
|
+
},
|
|
1270
|
+
{
|
|
1271
|
+
name: "suppressions",
|
|
1272
|
+
describe: "Manage the suppression list",
|
|
1273
|
+
scoped: true,
|
|
1274
|
+
idName: "email",
|
|
1275
|
+
list: { ...cursor },
|
|
1276
|
+
create: [{ flag: "--email <email>", key: "email" }],
|
|
1277
|
+
remove: true
|
|
1278
|
+
},
|
|
1279
|
+
{
|
|
1280
|
+
name: "segments",
|
|
1281
|
+
describe: "Manage segments",
|
|
1282
|
+
scoped: true,
|
|
1283
|
+
list: {},
|
|
1284
|
+
get: true,
|
|
1285
|
+
create: [
|
|
1286
|
+
{ flag: "--name <name>", key: "name" },
|
|
1287
|
+
{ flag: "--filter <json>", key: "filter", json: true }
|
|
1288
|
+
],
|
|
1289
|
+
update: [
|
|
1290
|
+
{ flag: "--name <name>", key: "name" },
|
|
1291
|
+
{ flag: "--filter <json>", key: "filter", json: true }
|
|
1292
|
+
],
|
|
1293
|
+
remove: true,
|
|
1294
|
+
actions: [
|
|
1295
|
+
{
|
|
1296
|
+
name: "preview",
|
|
1297
|
+
describe: "Count contacts matching a filter (--filter <json|@file>)",
|
|
1298
|
+
method: "post",
|
|
1299
|
+
needsId: false,
|
|
1300
|
+
suffix: "/preview",
|
|
1301
|
+
fields: [{ flag: "--filter <json>", key: "filter", json: true }]
|
|
1302
|
+
}
|
|
1303
|
+
]
|
|
1304
|
+
},
|
|
1305
|
+
// -- Sequences ------------------------------------------------------------
|
|
1306
|
+
{
|
|
1307
|
+
name: "sequences",
|
|
1308
|
+
describe: "Manage drip sequences",
|
|
1309
|
+
scoped: true,
|
|
1310
|
+
list: {},
|
|
1311
|
+
get: true,
|
|
1312
|
+
create: [{ flag: "--name <name>", key: "name" }],
|
|
1313
|
+
update: [
|
|
1314
|
+
{ flag: "--name <name>", key: "name" },
|
|
1315
|
+
{ flag: "--status <status>", key: "status", description: "active | archived" }
|
|
1316
|
+
],
|
|
1317
|
+
remove: true,
|
|
1318
|
+
actions: [
|
|
1319
|
+
{
|
|
1320
|
+
name: "draft",
|
|
1321
|
+
describe: "Save the draft graph + trigger",
|
|
1322
|
+
method: "put",
|
|
1323
|
+
suffix: "/draft",
|
|
1324
|
+
fields: [
|
|
1325
|
+
{ flag: "--graph <json>", key: "graph", json: true },
|
|
1326
|
+
{ flag: "--trigger <json>", key: "trigger", json: true },
|
|
1327
|
+
{ flag: "--expected-revision <n>", key: "expected_revision", number: true }
|
|
1328
|
+
]
|
|
1329
|
+
},
|
|
1330
|
+
{
|
|
1331
|
+
name: "publish",
|
|
1332
|
+
describe: "Publish the current draft",
|
|
1333
|
+
method: "post",
|
|
1334
|
+
suffix: "/publish"
|
|
1335
|
+
},
|
|
1336
|
+
{
|
|
1337
|
+
name: "preview",
|
|
1338
|
+
describe: "Render an email node (--node-id <id>)",
|
|
1339
|
+
method: "post",
|
|
1340
|
+
suffix: "/preview",
|
|
1341
|
+
fields: [{ flag: "--node-id <id>", key: "nodeId" }]
|
|
1342
|
+
},
|
|
1343
|
+
{
|
|
1344
|
+
name: "test-email",
|
|
1345
|
+
describe: "Send a test email (--node-id <id> --to <email>)",
|
|
1346
|
+
method: "post",
|
|
1347
|
+
suffix: "/test-email",
|
|
1348
|
+
fields: [
|
|
1349
|
+
{ flag: "--node-id <id>", key: "nodeId" },
|
|
1350
|
+
{ flag: "--to <email>", key: "to" }
|
|
1351
|
+
]
|
|
1352
|
+
},
|
|
1353
|
+
{
|
|
1354
|
+
name: "activity",
|
|
1355
|
+
describe: "Show enrollment activity",
|
|
1356
|
+
method: "get",
|
|
1357
|
+
suffix: "/activity"
|
|
1358
|
+
},
|
|
1359
|
+
{
|
|
1360
|
+
name: "versions",
|
|
1361
|
+
describe: "List published versions",
|
|
1362
|
+
method: "get",
|
|
1363
|
+
suffix: "/versions"
|
|
1364
|
+
}
|
|
1365
|
+
]
|
|
1366
|
+
},
|
|
1367
|
+
// -- Broadcasts -----------------------------------------------------------
|
|
1368
|
+
{
|
|
1369
|
+
name: "broadcasts",
|
|
1370
|
+
describe: "Manage broadcasts",
|
|
1371
|
+
scoped: true,
|
|
1372
|
+
list: {},
|
|
1373
|
+
get: true,
|
|
1374
|
+
create: [{ flag: "--name <name>", key: "name" }],
|
|
1375
|
+
update: [
|
|
1376
|
+
{ flag: "--name <name>", key: "name" },
|
|
1377
|
+
{ flag: "--subject <subject>", key: "subject" },
|
|
1378
|
+
{ flag: "--preview <preview>", key: "preview" },
|
|
1379
|
+
{ flag: "--audience <json>", key: "audience", json: true },
|
|
1380
|
+
{ flag: "--template-id <id>", key: "template_id" },
|
|
1381
|
+
{ flag: "--body-doc <json>", key: "body_doc", json: true }
|
|
1382
|
+
],
|
|
1383
|
+
remove: true,
|
|
1384
|
+
actions: [
|
|
1385
|
+
{
|
|
1386
|
+
name: "preflight",
|
|
1387
|
+
describe: "Validate before sending",
|
|
1388
|
+
method: "post",
|
|
1389
|
+
suffix: "/preflight"
|
|
1390
|
+
},
|
|
1391
|
+
{
|
|
1392
|
+
name: "schedule",
|
|
1393
|
+
describe: "Schedule (--scheduled-at <iso>) or send now (omit)",
|
|
1394
|
+
method: "post",
|
|
1395
|
+
suffix: "/schedule",
|
|
1396
|
+
fields: [{ flag: "--scheduled-at <iso>", key: "scheduled_at" }]
|
|
1397
|
+
},
|
|
1398
|
+
{
|
|
1399
|
+
name: "cancel",
|
|
1400
|
+
describe: "Cancel a scheduled or sending broadcast",
|
|
1401
|
+
method: "post",
|
|
1402
|
+
suffix: "/cancel"
|
|
1403
|
+
},
|
|
1404
|
+
{
|
|
1405
|
+
name: "live",
|
|
1406
|
+
describe: "Live status, stats, and recent activity",
|
|
1407
|
+
method: "get",
|
|
1408
|
+
suffix: "/live"
|
|
1409
|
+
}
|
|
1410
|
+
]
|
|
1411
|
+
},
|
|
1412
|
+
// -- Templates ------------------------------------------------------------
|
|
1413
|
+
{
|
|
1414
|
+
name: "templates",
|
|
1415
|
+
describe: "Manage email templates",
|
|
1416
|
+
scoped: true,
|
|
1417
|
+
list: {},
|
|
1418
|
+
get: true,
|
|
1419
|
+
create: [
|
|
1420
|
+
{ flag: "--name <name>", key: "name" },
|
|
1421
|
+
{ flag: "--design <json>", key: "design", json: true }
|
|
1422
|
+
],
|
|
1423
|
+
update: [
|
|
1424
|
+
{ flag: "--name <name>", key: "name" },
|
|
1425
|
+
{ flag: "--design <json>", key: "design", json: true }
|
|
1426
|
+
],
|
|
1427
|
+
remove: true,
|
|
1428
|
+
actions: [
|
|
1429
|
+
{
|
|
1430
|
+
name: "default",
|
|
1431
|
+
describe: "Set as the project default",
|
|
1432
|
+
method: "post",
|
|
1433
|
+
suffix: "/default"
|
|
1434
|
+
},
|
|
1435
|
+
{
|
|
1436
|
+
name: "reset",
|
|
1437
|
+
describe: "Reset design to a preset (--preset plain|branded)",
|
|
1438
|
+
method: "post",
|
|
1439
|
+
suffix: "/reset",
|
|
1440
|
+
fields: [{ flag: "--preset <preset>", key: "preset" }]
|
|
1441
|
+
},
|
|
1442
|
+
{
|
|
1443
|
+
name: "logo",
|
|
1444
|
+
describe: "Upload a logo (--file <path>)",
|
|
1445
|
+
method: "post",
|
|
1446
|
+
suffix: "/logo",
|
|
1447
|
+
multipart: true,
|
|
1448
|
+
fields: [{ flag: "--file <path>", key: "file", file: true }]
|
|
1449
|
+
},
|
|
1450
|
+
{
|
|
1451
|
+
name: "remove-logo",
|
|
1452
|
+
describe: "Remove the template logo",
|
|
1453
|
+
method: "delete",
|
|
1454
|
+
suffix: "/logo"
|
|
1455
|
+
}
|
|
1456
|
+
]
|
|
1457
|
+
},
|
|
1458
|
+
// -- Settings -------------------------------------------------------------
|
|
1459
|
+
{
|
|
1460
|
+
name: "domains",
|
|
1461
|
+
describe: "Manage sending domains",
|
|
1462
|
+
scoped: true,
|
|
1463
|
+
list: {},
|
|
1464
|
+
create: [{ flag: "--domain <domain>", key: "domain" }],
|
|
1465
|
+
remove: true,
|
|
1466
|
+
actions: [
|
|
1467
|
+
{
|
|
1468
|
+
name: "verify",
|
|
1469
|
+
describe: "Re-check DKIM verification",
|
|
1470
|
+
method: "post",
|
|
1471
|
+
suffix: "/verify"
|
|
1472
|
+
}
|
|
1473
|
+
]
|
|
1474
|
+
},
|
|
1475
|
+
{
|
|
1476
|
+
// Project ingestion keys (lt_live_*) authenticate the SDKs / ingestion API.
|
|
1477
|
+
// The dashboard calls these "Project tokens"; `keys` stays as a hidden alias.
|
|
1478
|
+
name: "project-tokens",
|
|
1479
|
+
alias: "keys",
|
|
1480
|
+
segment: "keys",
|
|
1481
|
+
describe: "Manage project ingestion keys (lt_live_*, the SDK/ingestion credential)",
|
|
1482
|
+
scoped: true,
|
|
1483
|
+
list: {},
|
|
1484
|
+
create: [{ flag: "--name <name>", key: "name" }],
|
|
1485
|
+
remove: true
|
|
1486
|
+
}
|
|
1487
|
+
];
|
|
1488
|
+
function registerResourceCommands(program2) {
|
|
1489
|
+
for (const spec of SPECS) register(program2, spec);
|
|
1490
|
+
program2.command("me").description("Show the token's user, workspace, and role").action(async () => {
|
|
1491
|
+
try {
|
|
1492
|
+
const { data } = await mgmt.get("/v1/me");
|
|
1493
|
+
emit(data);
|
|
1494
|
+
} catch (err) {
|
|
1495
|
+
fail(err);
|
|
1496
|
+
}
|
|
1497
|
+
});
|
|
1498
|
+
program2.command("sender-identity").description("Set From / From-name / Reply-To (--from-email, --from-name, --reply-to-email)").option("-p, --project <slug>", "Project slug (default: connected project)").option("--from-email <email>", "From address").option("--from-name <name>", "From display name").option("--reply-to-email <email>", "Reply-To address").action(async (opts) => {
|
|
1499
|
+
try {
|
|
1500
|
+
const slug = await resolveProjectSlug(opts.project);
|
|
1501
|
+
const body = {};
|
|
1502
|
+
if (opts.fromEmail !== void 0) body.from_email = opts.fromEmail;
|
|
1503
|
+
if (opts.fromName !== void 0) body.from_name = opts.fromName;
|
|
1504
|
+
if (opts.replyToEmail !== void 0) body.reply_to_email = opts.replyToEmail;
|
|
1505
|
+
const { data } = await mgmt.put(`/v1/projects/${slug}/sender-identity`, body);
|
|
1506
|
+
emit(data);
|
|
1507
|
+
} catch (err) {
|
|
1508
|
+
fail(err);
|
|
1509
|
+
}
|
|
1510
|
+
});
|
|
1511
|
+
program2.command("sending-mode").description("Set the sending mode (letter_subdomain | byo_domain)").argument("<mode>", "letter_subdomain | byo_domain").option("-p, --project <slug>", "Project slug (default: connected project)").action(async (mode, opts) => {
|
|
1512
|
+
try {
|
|
1513
|
+
const slug = await resolveProjectSlug(opts.project);
|
|
1514
|
+
const { data } = await mgmt.put(`/v1/projects/${slug}/sending-mode`, {
|
|
1515
|
+
sending_mode: mode
|
|
1516
|
+
});
|
|
1517
|
+
emit(data);
|
|
1518
|
+
} catch (err) {
|
|
1519
|
+
fail(err);
|
|
1520
|
+
}
|
|
1521
|
+
});
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
// src/index.ts
|
|
1525
|
+
var program = new Command();
|
|
1526
|
+
program.name("letter").description("Connect your app to Letter, then manage it from the command line").version("0.3.0").option("--json", "Output raw JSON (for scripting / agents)").hook("preAction", (thisCommand) => {
|
|
1527
|
+
if (thisCommand.opts().json) setJsonMode(true);
|
|
56
1528
|
});
|
|
1529
|
+
registerLoginCommand(program);
|
|
1530
|
+
registerAuthCommands(program);
|
|
1531
|
+
registerStatusCommand(program);
|
|
1532
|
+
registerConfigCommands(program);
|
|
1533
|
+
registerResourceCommands(program);
|
|
1534
|
+
program.parseAsync();
|
|
1535
|
+
//# sourceMappingURL=index.js.map
|