@letterapp/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +56 -0
- package/dist/commands/login.js +151 -0
- package/dist/commands/registry.js +21 -0
- package/dist/index.js +56 -0
- package/dist/lib/api.js +29 -0
- package/dist/lib/args.js +49 -0
- package/dist/lib/browser.js +33 -0
- package/dist/lib/config.js +35 -0
- package/dist/lib/env-file.js +45 -0
- package/dist/lib/pm.js +72 -0
- package/dist/lib/ui.js +57 -0
- package/package.json +48 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 letter.app
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# @letterapp/cli
|
|
2
|
+
|
|
3
|
+
Connect your app to [Letter](https://letter.app) in one command. The CLI runs an
|
|
4
|
+
interactive, secure device login: the API key is provisioned by a browser
|
|
5
|
+
confirmation and written straight to your project's env file. **The key never
|
|
6
|
+
appears in your shell history, terminal output, or an agent's chat transcript.**
|
|
7
|
+
|
|
8
|
+
## Usage
|
|
9
|
+
|
|
10
|
+
From your project root:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npx @letterapp/cli
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
What happens:
|
|
17
|
+
|
|
18
|
+
1. The CLI prints a short confirmation code and (after you press Enter) opens
|
|
19
|
+
your browser to the Letter dashboard.
|
|
20
|
+
2. You confirm the code matches and pick the project to connect.
|
|
21
|
+
3. Letter mints a project API key and sends it to the CLI over a secure back
|
|
22
|
+
channel. The CLI writes `LETTER_API_KEY` to `.env.local` and stores a copy in
|
|
23
|
+
`~/.letter/credentials.json` for tooling (such as `@letterapp/mcp`).
|
|
24
|
+
4. It detects your package manager and installs `@letterapp/node`.
|
|
25
|
+
|
|
26
|
+
## Options
|
|
27
|
+
|
|
28
|
+
| Flag | Description |
|
|
29
|
+
| --- | --- |
|
|
30
|
+
| `--no-open` | Don't auto-open the browser; print the URL to open manually (headless/remote). |
|
|
31
|
+
| `--yes`, `-y` | Non-interactive: don't wait for Enter. Useful when an agent runs the command. |
|
|
32
|
+
| `--no-install` | Skip installing `@letterapp/node`. |
|
|
33
|
+
| `--base-url <url>` | Target a self-hosted or local Letter instance. |
|
|
34
|
+
| `--api-key <key>` | CI only. Writes the key without the device flow. Do not use interactively or in chat. |
|
|
35
|
+
|
|
36
|
+
## Security
|
|
37
|
+
|
|
38
|
+
The interactive flow follows the OAuth 2.0 Device Authorization Grant pattern
|
|
39
|
+
(`gh auth login` / `vercel login` style). The key is minted server-side only
|
|
40
|
+
after a human approves in the browser, then delivered to the CLI out of band. The
|
|
41
|
+
CLI confirms success without ever echoing the secret. Treat `.env.local` and
|
|
42
|
+
`~/.letter/credentials.json` as secrets and keep them out of source control.
|
|
43
|
+
|
|
44
|
+
## Roadmap
|
|
45
|
+
|
|
46
|
+
v1 ships the setup/login flow. The command registry and credential store are
|
|
47
|
+
built to extend: future versions add authenticated subcommands that reuse the
|
|
48
|
+
same stored credential, for example:
|
|
49
|
+
|
|
50
|
+
- `letter sequences` - list / create / configure automation sequences
|
|
51
|
+
- `letter broadcast` - create and send broadcasts
|
|
52
|
+
- `letter contacts`, `letter events`, `letter keys`, `letter status`
|
|
53
|
+
|
|
54
|
+
## License
|
|
55
|
+
|
|
56
|
+
MIT
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { parseArgs, flagString, flagBool } from "../lib/args.js";
|
|
2
|
+
import { resolveApiBase, DEFAULT_API_BASE, saveCredential } from "../lib/config.js";
|
|
3
|
+
import { startDeviceAuth, pollDeviceAuth } from "../lib/api.js";
|
|
4
|
+
import { openUrl } from "../lib/browser.js";
|
|
5
|
+
import { upsertEnv } from "../lib/env-file.js";
|
|
6
|
+
import { detectFramework, detectPackageManager, installCommand, runInstall, } from "../lib/pm.js";
|
|
7
|
+
import { banner, color, error, info, log, prompt, success, warn, } from "../lib/ui.js";
|
|
8
|
+
const SDK_PACKAGE = "@letterapp/node";
|
|
9
|
+
const ENV_FILE = ".env.local";
|
|
10
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
11
|
+
/**
|
|
12
|
+
* `letter login` / `letter init` (the default command). Runs the interactive
|
|
13
|
+
* device-authorization flow, writes the minted key to the project env + the
|
|
14
|
+
* shared credential store, and installs the SDK. The API key is NEVER printed.
|
|
15
|
+
*/
|
|
16
|
+
export async function runLogin(argv) {
|
|
17
|
+
const { flags } = parseArgs(argv);
|
|
18
|
+
const base = resolveApiBase(flagString(flags, "base-url"));
|
|
19
|
+
const cwd = process.cwd();
|
|
20
|
+
const autoYes = flagBool(flags, "yes") === true || flags.y === true;
|
|
21
|
+
const allowOpen = flagBool(flags, "open") !== false; // --no-open disables
|
|
22
|
+
const doInstall = flagBool(flags, "install") !== false; // --no-install disables
|
|
23
|
+
const ciKey = flagString(flags, "api-key");
|
|
24
|
+
banner();
|
|
25
|
+
// CI escape hatch: write the provided key non-interactively. Documented as
|
|
26
|
+
// for automation only - interactive/agent use should rely on the device flow
|
|
27
|
+
// so no secret is passed through the command line / chat.
|
|
28
|
+
if (ciKey) {
|
|
29
|
+
const entries = { LETTER_API_KEY: ciKey };
|
|
30
|
+
if (base !== DEFAULT_API_BASE)
|
|
31
|
+
entries.LETTER_BASE_URL = base;
|
|
32
|
+
const file = await upsertEnv(cwd, ENV_FILE, entries);
|
|
33
|
+
success(`Saved LETTER_API_KEY to ${rel(cwd, file)} (--api-key).`);
|
|
34
|
+
warn("--api-key is for CI. For interactive setup, run `letter login`.");
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
// 1. Begin the flow.
|
|
38
|
+
let flow;
|
|
39
|
+
try {
|
|
40
|
+
flow = await startDeviceAuth(base);
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
error(err.message);
|
|
44
|
+
return 1;
|
|
45
|
+
}
|
|
46
|
+
log(`${color.bold("Confirm this code in your browser:")}`);
|
|
47
|
+
log();
|
|
48
|
+
log(` ${color.cyan(color.bold(flow.user_code))}`);
|
|
49
|
+
log();
|
|
50
|
+
// 2. Open the browser (interactive: wait for Enter; otherwise auto/print).
|
|
51
|
+
const interactive = process.stdin.isTTY && !autoYes;
|
|
52
|
+
if (interactive && allowOpen) {
|
|
53
|
+
await prompt(color.dim("Press Enter to open your browser… "));
|
|
54
|
+
}
|
|
55
|
+
if (allowOpen)
|
|
56
|
+
openUrl(flow.verification_uri_complete);
|
|
57
|
+
info(`If your browser didn't open, visit:`);
|
|
58
|
+
log(` ${color.blue(flow.verification_uri_complete)}`);
|
|
59
|
+
log();
|
|
60
|
+
info("Waiting for you to approve… (Ctrl+C to cancel)");
|
|
61
|
+
// 3. Poll until the user approves/denies or the code expires.
|
|
62
|
+
const deadline = Date.now() + flow.expires_in * 1000;
|
|
63
|
+
let intervalMs = Math.max(1, flow.interval) * 1000;
|
|
64
|
+
while (Date.now() < deadline) {
|
|
65
|
+
await sleep(intervalMs);
|
|
66
|
+
let res;
|
|
67
|
+
try {
|
|
68
|
+
res = await pollDeviceAuth(base, flow.device_code);
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
error(err.message);
|
|
72
|
+
return 1;
|
|
73
|
+
}
|
|
74
|
+
if (res.status === "authorization_pending")
|
|
75
|
+
continue;
|
|
76
|
+
if (res.status === "slow_down") {
|
|
77
|
+
intervalMs += 1000;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (res.status === "access_denied") {
|
|
81
|
+
error("Request denied in the browser. Nothing was changed.");
|
|
82
|
+
return 1;
|
|
83
|
+
}
|
|
84
|
+
if (res.status === "expired_token") {
|
|
85
|
+
error("This login expired. Run the command again to retry.");
|
|
86
|
+
return 1;
|
|
87
|
+
}
|
|
88
|
+
// Approved.
|
|
89
|
+
return finish(res.api_key, res.base_url, res.project, cwd, doInstall);
|
|
90
|
+
}
|
|
91
|
+
error("Timed out waiting for approval. Run the command again to retry.");
|
|
92
|
+
return 1;
|
|
93
|
+
}
|
|
94
|
+
async function finish(apiKey, baseUrl, project, cwd, doInstall) {
|
|
95
|
+
log();
|
|
96
|
+
success(`Approved for project ${color.bold(project.name)}.`);
|
|
97
|
+
// Write the key to the project env (value never printed) + shared store.
|
|
98
|
+
const entries = { LETTER_API_KEY: apiKey };
|
|
99
|
+
if (baseUrl && baseUrl !== DEFAULT_API_BASE)
|
|
100
|
+
entries.LETTER_BASE_URL = baseUrl;
|
|
101
|
+
const envFile = await upsertEnv(cwd, ENV_FILE, entries);
|
|
102
|
+
success(`Saved LETTER_API_KEY to ${rel(cwd, envFile)}.`);
|
|
103
|
+
try {
|
|
104
|
+
const credFile = await saveCredential({
|
|
105
|
+
apiKey,
|
|
106
|
+
baseUrl: baseUrl || DEFAULT_API_BASE,
|
|
107
|
+
project,
|
|
108
|
+
savedAt: new Date().toISOString(),
|
|
109
|
+
});
|
|
110
|
+
success(`Stored credentials in ${tildify(credFile)} for tooling (MCP).`);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
warn("Could not write ~/.letter/credentials.json (continuing).");
|
|
114
|
+
}
|
|
115
|
+
// Install the SDK.
|
|
116
|
+
const pm = await detectPackageManager(cwd);
|
|
117
|
+
if (doInstall) {
|
|
118
|
+
info(`Installing ${SDK_PACKAGE} with ${pm}…`);
|
|
119
|
+
const code = await runInstall(pm, SDK_PACKAGE, cwd);
|
|
120
|
+
if (code === 0)
|
|
121
|
+
success(`Installed ${SDK_PACKAGE}.`);
|
|
122
|
+
else
|
|
123
|
+
warn(`Install failed. Run: ${installCommand(pm, SDK_PACKAGE)}`);
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
info(`Skipped install. Run: ${installCommand(pm, SDK_PACKAGE)}`);
|
|
127
|
+
}
|
|
128
|
+
const framework = await detectFramework(cwd);
|
|
129
|
+
printNextSteps(framework);
|
|
130
|
+
return 0;
|
|
131
|
+
}
|
|
132
|
+
function printNextSteps(framework) {
|
|
133
|
+
log();
|
|
134
|
+
log(color.bold("Next steps"));
|
|
135
|
+
if (framework)
|
|
136
|
+
log(color.dim(`Detected ${framework}.`));
|
|
137
|
+
log(` 1. Create a server-side client that reads ${color.cyan("process.env.LETTER_API_KEY")}.`);
|
|
138
|
+
log(` 2. Call ${color.cyan("letter.identify(...)")} where users sign up or log in.`);
|
|
139
|
+
log(` 3. Call ${color.cyan("letter.track(...)")} on 2-3 key actions.`);
|
|
140
|
+
log();
|
|
141
|
+
log(color.dim("Full guide: https://letter.app/docs/agent-setup"));
|
|
142
|
+
log(color.dim("Your API key is in .env.local - keep it out of source control."));
|
|
143
|
+
log();
|
|
144
|
+
}
|
|
145
|
+
function rel(cwd, file) {
|
|
146
|
+
return file.startsWith(cwd) ? file.slice(cwd.length + 1) || file : file;
|
|
147
|
+
}
|
|
148
|
+
function tildify(file) {
|
|
149
|
+
const home = process.env.HOME || process.env.USERPROFILE;
|
|
150
|
+
return home && file.startsWith(home) ? `~${file.slice(home.length)}` : file;
|
|
151
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { runLogin } from "./login.js";
|
|
2
|
+
/**
|
|
3
|
+
* Command registry. v1 ships only the device-login setup flow (as `login`,
|
|
4
|
+
* `init`, and the default), but the shape here is the extension point: future
|
|
5
|
+
* authenticated subcommands (`sequences`, `broadcast`, `contacts`, `events`,
|
|
6
|
+
* `keys`, `status`) register the same way and reuse the credential store in
|
|
7
|
+
* lib/config.ts + the API client in lib/api.ts. See README "Roadmap".
|
|
8
|
+
*/
|
|
9
|
+
export const COMMANDS = [
|
|
10
|
+
{
|
|
11
|
+
name: "login",
|
|
12
|
+
aliases: ["init"],
|
|
13
|
+
summary: "Connect this project to Letter (interactive device login)",
|
|
14
|
+
run: runLogin,
|
|
15
|
+
},
|
|
16
|
+
];
|
|
17
|
+
/** The command that runs when none is given. */
|
|
18
|
+
export const DEFAULT_COMMAND = "login";
|
|
19
|
+
export function findCommand(name) {
|
|
20
|
+
return COMMANDS.find((c) => c.name === name || c.aliases?.includes(name));
|
|
21
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { COMMANDS, DEFAULT_COMMAND, findCommand } from "./commands/registry.js";
|
|
3
|
+
import { banner, color, error, log } from "./lib/ui.js";
|
|
4
|
+
const VERSION = "0.1.0";
|
|
5
|
+
function printHelp() {
|
|
6
|
+
banner();
|
|
7
|
+
log(`${color.bold("Usage")} letter <command> [options]`);
|
|
8
|
+
log();
|
|
9
|
+
log(color.bold("Commands"));
|
|
10
|
+
for (const c of COMMANDS) {
|
|
11
|
+
const names = [c.name, ...(c.aliases ?? [])].join(", ");
|
|
12
|
+
log(` ${names.padEnd(18)} ${color.dim(c.summary)}`);
|
|
13
|
+
}
|
|
14
|
+
log(` ${"help".padEnd(18)} ${color.dim("Show this help")}`);
|
|
15
|
+
log();
|
|
16
|
+
log(color.bold("Login options"));
|
|
17
|
+
log(` ${"--no-open".padEnd(18)} ${color.dim("Don't auto-open the browser; print the URL")}`);
|
|
18
|
+
log(` ${"--yes, -y".padEnd(18)} ${color.dim("Non-interactive (for agents/CI prompts)")}`);
|
|
19
|
+
log(` ${"--no-install".padEnd(18)} ${color.dim("Skip installing @letterapp/node")}`);
|
|
20
|
+
log(` ${"--base-url <url>".padEnd(18)} ${color.dim("Target a self-hosted / local Letter")}`);
|
|
21
|
+
log(` ${"--api-key <key>".padEnd(18)} ${color.dim("CI only: write a key without the device flow")}`);
|
|
22
|
+
log();
|
|
23
|
+
log(color.dim("Docs: https://letter.app/docs/agent-setup"));
|
|
24
|
+
log();
|
|
25
|
+
}
|
|
26
|
+
async function main() {
|
|
27
|
+
const argv = process.argv.slice(2);
|
|
28
|
+
const first = argv[0];
|
|
29
|
+
if (first === "--version" || first === "-v") {
|
|
30
|
+
log(VERSION);
|
|
31
|
+
return 0;
|
|
32
|
+
}
|
|
33
|
+
if (first === "help" || first === "--help" || first === "-h") {
|
|
34
|
+
printHelp();
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
// No command, or a flag-first invocation (e.g. `letter --no-open`): run the
|
|
38
|
+
// default command with all args.
|
|
39
|
+
if (!first || first.startsWith("-")) {
|
|
40
|
+
const cmd = findCommand(DEFAULT_COMMAND);
|
|
41
|
+
return cmd.run(argv);
|
|
42
|
+
}
|
|
43
|
+
const cmd = findCommand(first);
|
|
44
|
+
if (!cmd) {
|
|
45
|
+
error(`Unknown command: ${first}`);
|
|
46
|
+
log(`Run ${color.cyan("letter help")} to see available commands.`);
|
|
47
|
+
return 1;
|
|
48
|
+
}
|
|
49
|
+
return cmd.run(argv.slice(1));
|
|
50
|
+
}
|
|
51
|
+
main()
|
|
52
|
+
.then((code) => process.exit(code))
|
|
53
|
+
.catch((err) => {
|
|
54
|
+
error(err instanceof Error ? err.message : String(err));
|
|
55
|
+
process.exit(1);
|
|
56
|
+
});
|
package/dist/lib/api.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
const USER_AGENT = "@letterapp/cli";
|
|
2
|
+
/** Starts a device-authorization flow against the given API base. */
|
|
3
|
+
export async function startDeviceAuth(base) {
|
|
4
|
+
const res = await fetch(`${base}/v1/cli/auth/start`, {
|
|
5
|
+
method: "POST",
|
|
6
|
+
headers: { "content-type": "application/json", "user-agent": USER_AGENT },
|
|
7
|
+
body: "{}",
|
|
8
|
+
});
|
|
9
|
+
if (!res.ok) {
|
|
10
|
+
throw new Error(`Could not start login (HTTP ${res.status}). Is ${base} reachable?`);
|
|
11
|
+
}
|
|
12
|
+
return (await res.json());
|
|
13
|
+
}
|
|
14
|
+
/** Polls once for approval. */
|
|
15
|
+
export async function pollDeviceAuth(base, deviceCode) {
|
|
16
|
+
const res = await fetch(`${base}/v1/cli/auth/poll`, {
|
|
17
|
+
method: "POST",
|
|
18
|
+
headers: { "content-type": "application/json", "user-agent": USER_AGENT },
|
|
19
|
+
body: JSON.stringify({ device_code: deviceCode }),
|
|
20
|
+
});
|
|
21
|
+
if (res.status === 429) {
|
|
22
|
+
const retryAfter = Number(res.headers.get("retry-after") ?? "5");
|
|
23
|
+
return { status: "slow_down", retryAfter };
|
|
24
|
+
}
|
|
25
|
+
if (!res.ok) {
|
|
26
|
+
throw new Error(`Login poll failed (HTTP ${res.status}).`);
|
|
27
|
+
}
|
|
28
|
+
return (await res.json());
|
|
29
|
+
}
|
package/dist/lib/args.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export function parseArgs(argv) {
|
|
2
|
+
const positionals = [];
|
|
3
|
+
const flags = {};
|
|
4
|
+
for (let i = 0; i < argv.length; i++) {
|
|
5
|
+
const arg = argv[i];
|
|
6
|
+
if (arg.startsWith("--")) {
|
|
7
|
+
const body = arg.slice(2);
|
|
8
|
+
const eq = body.indexOf("=");
|
|
9
|
+
if (eq !== -1) {
|
|
10
|
+
flags[body.slice(0, eq)] = body.slice(eq + 1);
|
|
11
|
+
}
|
|
12
|
+
else if (body.startsWith("no-")) {
|
|
13
|
+
flags[body.slice(3)] = false;
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
const next = argv[i + 1];
|
|
17
|
+
if (next && !next.startsWith("-")) {
|
|
18
|
+
flags[body] = next;
|
|
19
|
+
i++;
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
flags[body] = true;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
else if (arg.startsWith("-") && arg.length > 1) {
|
|
27
|
+
for (const ch of arg.slice(1))
|
|
28
|
+
flags[ch] = true;
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
positionals.push(arg);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return { positionals, flags };
|
|
35
|
+
}
|
|
36
|
+
export function flagString(flags, ...names) {
|
|
37
|
+
for (const n of names) {
|
|
38
|
+
const v = flags[n];
|
|
39
|
+
if (typeof v === "string")
|
|
40
|
+
return v;
|
|
41
|
+
}
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
export function flagBool(flags, name) {
|
|
45
|
+
const v = flags[name];
|
|
46
|
+
if (typeof v === "boolean")
|
|
47
|
+
return v;
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
/**
|
|
3
|
+
* Opens `url` in the default browser. Best-effort and non-blocking: if it fails
|
|
4
|
+
* (headless box, no DISPLAY) the caller has already printed the URL so the user
|
|
5
|
+
* can open it manually. Returns true if a launcher was spawned.
|
|
6
|
+
*/
|
|
7
|
+
export function openUrl(url) {
|
|
8
|
+
const platform = process.platform;
|
|
9
|
+
let command;
|
|
10
|
+
let args;
|
|
11
|
+
if (platform === "darwin") {
|
|
12
|
+
command = "open";
|
|
13
|
+
args = [url];
|
|
14
|
+
}
|
|
15
|
+
else if (platform === "win32") {
|
|
16
|
+
command = "cmd";
|
|
17
|
+
// `start` needs an empty title arg; the comma-free form avoids quoting woes.
|
|
18
|
+
args = ["/c", "start", "", url];
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
command = "xdg-open";
|
|
22
|
+
args = [url];
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
const child = spawn(command, args, { stdio: "ignore", detached: true });
|
|
26
|
+
child.on("error", () => { });
|
|
27
|
+
child.unref();
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
/** The SDK's built-in default; when the resolved base equals this we don't
|
|
5
|
+
* write LETTER_BASE_URL to the project env. */
|
|
6
|
+
export const DEFAULT_API_BASE = "https://api.letter.app";
|
|
7
|
+
/** Resolve the API base: explicit flag > env > prod default. */
|
|
8
|
+
export function resolveApiBase(flagValue) {
|
|
9
|
+
const raw = flagValue || process.env.LETTER_BASE_URL || DEFAULT_API_BASE;
|
|
10
|
+
return raw.replace(/\/$/, "");
|
|
11
|
+
}
|
|
12
|
+
function credentialsPath() {
|
|
13
|
+
return path.join(homedir(), ".letter", "credentials.json");
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Persists the credential to ~/.letter/credentials.json with owner-only
|
|
17
|
+
* permissions. Read by tools like @letterapp/mcp so the secret never has to be
|
|
18
|
+
* pasted into an MCP config.
|
|
19
|
+
*/
|
|
20
|
+
export async function saveCredential(cred) {
|
|
21
|
+
const file = credentialsPath();
|
|
22
|
+
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
23
|
+
await writeFile(file, `${JSON.stringify(cred, null, 2)}\n`, { mode: 0o600 });
|
|
24
|
+
return file;
|
|
25
|
+
}
|
|
26
|
+
/** Reads the stored credential, or null if none. */
|
|
27
|
+
export async function readCredential() {
|
|
28
|
+
try {
|
|
29
|
+
const raw = await readFile(credentialsPath(), "utf8");
|
|
30
|
+
return JSON.parse(raw);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { readFile, writeFile, stat } from "node:fs/promises";
|
|
3
|
+
/**
|
|
4
|
+
* Upserts `key=value` in an env file, creating it if needed. Existing keys are
|
|
5
|
+
* replaced in place; new keys are appended. Returns the file path. The value is
|
|
6
|
+
* never logged by this module - callers print only the key name.
|
|
7
|
+
*/
|
|
8
|
+
export async function upsertEnv(cwd, file, entries) {
|
|
9
|
+
const filePath = path.join(cwd, file);
|
|
10
|
+
let contents = "";
|
|
11
|
+
try {
|
|
12
|
+
contents = await readFile(filePath, "utf8");
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
contents = "";
|
|
16
|
+
}
|
|
17
|
+
let next = contents;
|
|
18
|
+
for (const [key, value] of Object.entries(entries)) {
|
|
19
|
+
const line = `${key}=${value}`;
|
|
20
|
+
const re = new RegExp(`^${escapeRegExp(key)}=.*$`, "m");
|
|
21
|
+
if (re.test(next)) {
|
|
22
|
+
next = next.replace(re, line);
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
if (next.length && !next.endsWith("\n"))
|
|
26
|
+
next += "\n";
|
|
27
|
+
next += `${line}\n`;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
await writeFile(filePath, next, "utf8");
|
|
31
|
+
return filePath;
|
|
32
|
+
}
|
|
33
|
+
/** True if a file exists at `cwd/name`. */
|
|
34
|
+
export async function fileExists(cwd, name) {
|
|
35
|
+
try {
|
|
36
|
+
await stat(path.join(cwd, name));
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function escapeRegExp(s) {
|
|
44
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
45
|
+
}
|
package/dist/lib/pm.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { fileExists } from "./env-file.js";
|
|
3
|
+
/**
|
|
4
|
+
* Detects the package manager from lockfiles (then the `npm_config_user_agent`
|
|
5
|
+
* of the running process), defaulting to npm.
|
|
6
|
+
*/
|
|
7
|
+
export async function detectPackageManager(cwd) {
|
|
8
|
+
if (await fileExists(cwd, "pnpm-lock.yaml"))
|
|
9
|
+
return "pnpm";
|
|
10
|
+
if (await fileExists(cwd, "yarn.lock"))
|
|
11
|
+
return "yarn";
|
|
12
|
+
if (await fileExists(cwd, "bun.lockb"))
|
|
13
|
+
return "bun";
|
|
14
|
+
if (await fileExists(cwd, "package-lock.json"))
|
|
15
|
+
return "npm";
|
|
16
|
+
const ua = process.env.npm_config_user_agent ?? "";
|
|
17
|
+
if (ua.startsWith("pnpm"))
|
|
18
|
+
return "pnpm";
|
|
19
|
+
if (ua.startsWith("yarn"))
|
|
20
|
+
return "yarn";
|
|
21
|
+
if (ua.startsWith("bun"))
|
|
22
|
+
return "bun";
|
|
23
|
+
return "npm";
|
|
24
|
+
}
|
|
25
|
+
/** Detects a likely web framework for friendlier guidance. */
|
|
26
|
+
export async function detectFramework(cwd) {
|
|
27
|
+
try {
|
|
28
|
+
const pkgPath = `${cwd}/package.json`;
|
|
29
|
+
const { readFile } = await import("node:fs/promises");
|
|
30
|
+
const pkg = JSON.parse(await readFile(pkgPath, "utf8"));
|
|
31
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
32
|
+
if (deps.next)
|
|
33
|
+
return "Next.js";
|
|
34
|
+
if (deps.nuxt)
|
|
35
|
+
return "Nuxt";
|
|
36
|
+
if (deps["@remix-run/node"] || deps["@remix-run/react"])
|
|
37
|
+
return "Remix";
|
|
38
|
+
if (deps.express)
|
|
39
|
+
return "Express";
|
|
40
|
+
if (deps.fastify)
|
|
41
|
+
return "Fastify";
|
|
42
|
+
if (deps.hono)
|
|
43
|
+
return "Hono";
|
|
44
|
+
if (deps["@sveltejs/kit"])
|
|
45
|
+
return "SvelteKit";
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export function installCommand(pm, pkg) {
|
|
53
|
+
switch (pm) {
|
|
54
|
+
case "pnpm":
|
|
55
|
+
return `pnpm add ${pkg}`;
|
|
56
|
+
case "yarn":
|
|
57
|
+
return `yarn add ${pkg}`;
|
|
58
|
+
case "bun":
|
|
59
|
+
return `bun add ${pkg}`;
|
|
60
|
+
default:
|
|
61
|
+
return `npm install ${pkg}`;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** Runs the install command, streaming output. Resolves to the exit code. */
|
|
65
|
+
export function runInstall(pm, pkg, cwd) {
|
|
66
|
+
const args = pm === "npm" ? ["install", pkg] : pm === "yarn" ? ["add", pkg] : ["add", pkg];
|
|
67
|
+
return new Promise((resolve) => {
|
|
68
|
+
const child = spawn(pm, args, { cwd, stdio: "inherit", shell: process.platform === "win32" });
|
|
69
|
+
child.on("error", () => resolve(1));
|
|
70
|
+
child.on("close", (code) => resolve(code ?? 1));
|
|
71
|
+
});
|
|
72
|
+
}
|
package/dist/lib/ui.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import readline from "node:readline";
|
|
2
|
+
const useColor = process.stdout.isTTY && !process.env.NO_COLOR && process.env.TERM !== "dumb";
|
|
3
|
+
function wrap(open, close) {
|
|
4
|
+
return (s) => useColor ? `\u001b[${open}m${s}\u001b[${close}m` : s;
|
|
5
|
+
}
|
|
6
|
+
export const color = {
|
|
7
|
+
bold: wrap(1, 22),
|
|
8
|
+
dim: wrap(2, 22),
|
|
9
|
+
red: wrap(31, 39),
|
|
10
|
+
green: wrap(32, 39),
|
|
11
|
+
yellow: wrap(33, 39),
|
|
12
|
+
blue: wrap(34, 39),
|
|
13
|
+
cyan: wrap(36, 39),
|
|
14
|
+
gray: wrap(90, 39),
|
|
15
|
+
};
|
|
16
|
+
export function log(msg = "") {
|
|
17
|
+
process.stdout.write(`${msg}\n`);
|
|
18
|
+
}
|
|
19
|
+
export function info(msg) {
|
|
20
|
+
log(`${color.cyan("›")} ${msg}`);
|
|
21
|
+
}
|
|
22
|
+
export function success(msg) {
|
|
23
|
+
log(`${color.green("✓")} ${msg}`);
|
|
24
|
+
}
|
|
25
|
+
export function warn(msg) {
|
|
26
|
+
log(`${color.yellow("!")} ${msg}`);
|
|
27
|
+
}
|
|
28
|
+
export function error(msg) {
|
|
29
|
+
process.stderr.write(`${color.red("✗")} ${msg}\n`);
|
|
30
|
+
}
|
|
31
|
+
export function step(n, total, msg) {
|
|
32
|
+
log(`${color.dim(`[${n}/${total}]`)} ${msg}`);
|
|
33
|
+
}
|
|
34
|
+
/** The Letter wordmark banner. */
|
|
35
|
+
export function banner() {
|
|
36
|
+
log();
|
|
37
|
+
log(` ${color.bold("Letter")}${color.red(".")} ${color.dim("CLI")}`);
|
|
38
|
+
log();
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Prompts the user and resolves with their input. Returns "" immediately on a
|
|
42
|
+
* non-interactive stdin so automated/agent runs don't hang.
|
|
43
|
+
*/
|
|
44
|
+
export function prompt(question) {
|
|
45
|
+
if (!process.stdin.isTTY)
|
|
46
|
+
return Promise.resolve("");
|
|
47
|
+
const rl = readline.createInterface({
|
|
48
|
+
input: process.stdin,
|
|
49
|
+
output: process.stdout,
|
|
50
|
+
});
|
|
51
|
+
return new Promise((resolve) => {
|
|
52
|
+
rl.question(question, (answer) => {
|
|
53
|
+
rl.close();
|
|
54
|
+
resolve(answer);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@letterapp/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Letter CLI - connect your app to Letter in one command. Interactive, secure device login: no API key ever touches your shell or chat.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://letter.app",
|
|
7
|
+
"author": "letter.app",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/vincenzor/letter-cli.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/vincenzor/letter-cli/issues"
|
|
14
|
+
},
|
|
15
|
+
"type": "module",
|
|
16
|
+
"bin": {
|
|
17
|
+
"letter": "dist/index.js"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"letter",
|
|
29
|
+
"letterapp",
|
|
30
|
+
"cli",
|
|
31
|
+
"analytics",
|
|
32
|
+
"email",
|
|
33
|
+
"onboarding"
|
|
34
|
+
],
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsc -p tsconfig.json",
|
|
40
|
+
"clean": "rm -rf dist tsconfig.tsbuildinfo",
|
|
41
|
+
"typecheck": "tsc --noEmit",
|
|
42
|
+
"prepublishOnly": "npm run clean && npm run build"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/node": "^22.10.2",
|
|
46
|
+
"typescript": "^5.7.2"
|
|
47
|
+
}
|
|
48
|
+
}
|