deploysapp-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/README.md ADDED
@@ -0,0 +1,163 @@
1
+ # deploysapp-cli
2
+
3
+ Command-line interface for [DeploysApp](https://deploysapp.com) — deploy, manage, and monitor your services from the terminal.
4
+
5
+ ```
6
+ npm install -g deploysapp-cli
7
+ ```
8
+
9
+ Requires **Node >= 20**.
10
+
11
+ Binary names: `deploysapp` and `dsa` (short alias).
12
+
13
+ ---
14
+
15
+ ## Quick start
16
+
17
+ ```sh
18
+ deploysapp login # authenticate this device (opens browser)
19
+ deploysapp link # bind the current directory to a service
20
+ deploysapp deploy # redeploy from git and stream the build log
21
+ ```
22
+
23
+ ---
24
+
25
+ ## Authentication
26
+
27
+ ### Interactive (recommended for local dev)
28
+
29
+ ```sh
30
+ deploysapp login
31
+ ```
32
+
33
+ Opens a browser-based device-code flow. Credentials are saved to `~/.deploysapp/config.json` (mode 0600).
34
+
35
+ ```sh
36
+ deploysapp logout # remove stored credentials
37
+ deploysapp whoami # print the authenticated account
38
+ ```
39
+
40
+ ### CI / headless environments
41
+
42
+ Skip `login` entirely — set env vars instead:
43
+
44
+ ```sh
45
+ export DEPLOYSAPP_API_KEY=dsa_xxxxxxxxxxxx
46
+ # optional: point at a self-hosted API
47
+ export DEPLOYSAPP_API_URL=https://api.your-instance.example.com
48
+ ```
49
+
50
+ `DEPLOYSAPP_API_KEY` takes precedence over the stored config file.
51
+
52
+ ---
53
+
54
+ ## The `.deploysapp.json` link file
55
+
56
+ Running `deploysapp link` (or `dsa link`) writes a `.deploysapp.json` file in the current directory that records the service (and project) id:
57
+
58
+ ```json
59
+ {
60
+ "serviceId": "svc_abc123",
61
+ "projectId": "proj_xyz789"
62
+ }
63
+ ```
64
+
65
+ All commands that operate on a specific service read this file automatically. You can override it at any time with the `--service <id>` flag.
66
+
67
+ ---
68
+
69
+ ## Command reference
70
+
71
+ ### Auth
72
+
73
+ | Command | Description | Example |
74
+ |---------|-------------|---------|
75
+ | `login [--no-open]` | Authenticate this device via browser | `dsa login` |
76
+ | `logout` | Remove stored credentials | `dsa logout` |
77
+ | `whoami` | Print the currently authenticated account | `dsa whoami` |
78
+
79
+ ### Linking
80
+
81
+ | Command | Description | Example |
82
+ |---------|-------------|---------|
83
+ | `link [--service <id>]` | Bind the current directory to a service (writes `.deploysapp.json`) | `dsa link` |
84
+
85
+ ### Deployment
86
+
87
+ | Command | Description | Example |
88
+ |---------|-------------|---------|
89
+ | `deploy [--service <id>]` | Trigger a redeploy from git and stream the build log | `dsa deploy --service svc_abc123` |
90
+
91
+ ### Logs
92
+
93
+ ```
94
+ logs [--service <id>] [-f] [--build | --runtime] [--tail <n>]
95
+ ```
96
+
97
+ | Flag | Description |
98
+ |------|-------------|
99
+ | `--service <id>` | Target service (overrides `.deploysapp.json`) |
100
+ | `--build` | Show the latest build log |
101
+ | `--runtime` | Show runtime logs (default) |
102
+ | `-f, --follow` | Follow (stream) runtime logs |
103
+ | `--tail <n>` | Lines of history to show (default: 200) |
104
+
105
+ Examples:
106
+
107
+ ```sh
108
+ dsa logs # last 200 lines of runtime logs
109
+ dsa logs --build # latest build log
110
+ dsa logs -f # follow runtime logs live
111
+ dsa logs -f --tail 50 # follow, starting from last 50 lines
112
+ ```
113
+
114
+ ### Service lifecycle
115
+
116
+ All service commands accept `--service <id>` to override `.deploysapp.json`.
117
+
118
+ | Command | Description | Example |
119
+ |---------|-------------|---------|
120
+ | `ps` | List all services and their current status | `dsa ps` |
121
+ | `restart [--service <id>]` | Restart a service | `dsa restart` |
122
+ | `stop [--service <id>]` | Stop a service | `dsa stop --service svc_abc123` |
123
+ | `start [--service <id>]` | Start a stopped service | `dsa start` |
124
+ | `open [--service <id>]` | Open the service URL in the browser | `dsa open` |
125
+ | `scale --replicas <n> [--service <id>]` | Set the replica count | `dsa scale --replicas 3` |
126
+
127
+ ### Environment variables
128
+
129
+ All `env` subcommands accept `--service <id>`.
130
+
131
+ | Command | Description | Example |
132
+ |---------|-------------|---------|
133
+ | `env list` | List all env vars for a service | `dsa env list` |
134
+ | `env get <key>` | Print the value of a single env var | `dsa env get DATABASE_URL` |
135
+ | `env set <KEY=VALUE> [--restart]` | Set an env var (optionally restart) | `dsa env set PORT=3000 --restart` |
136
+ | `env rm <key> [--restart]` | Delete an env var (optionally restart) | `dsa env rm OLD_VAR --restart` |
137
+
138
+ ### Secrets
139
+
140
+ Project-scoped secrets can be attached to a service as an env var.
141
+
142
+ | Command | Description | Example |
143
+ |---------|-------------|---------|
144
+ | `secret list [--project <id>]` | List secrets for a project | `dsa secret list` |
145
+ | `secret attach <name> --as <ENV_KEY> [--project <id>] [--service <id>]` | Expose a secret to a service as an env var | `dsa secret attach db-password --as DATABASE_PASSWORD` |
146
+
147
+ ---
148
+
149
+ ## Configuration reference
150
+
151
+ | Source | Key | Default |
152
+ |--------|-----|---------|
153
+ | Env var | `DEPLOYSAPP_API_KEY` | — |
154
+ | Env var | `DEPLOYSAPP_API_URL` | `https://api.deploysapp.com` |
155
+ | File | `~/.deploysapp/config.json` | written by `login` |
156
+
157
+ Environment variables always take precedence over the config file.
158
+
159
+ ---
160
+
161
+ ## License
162
+
163
+ ISC
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { whoami, login, logout } from "../src/commands/auth.js";
4
+ import { link } from "../src/commands/link.js";
5
+ import { deploy } from "../src/commands/deploy.js";
6
+ import { logs } from "../src/commands/logs.js";
7
+ import { ps, restart, stop, start, scale, open } from "../src/commands/service.js";
8
+ import { envList, envGet, envSet, envRm } from "../src/commands/env.js";
9
+ import { secretList, secretAttach } from "../src/commands/secret.js";
10
+ import { printErr } from "../src/output.js";
11
+
12
+ const program = new Command();
13
+ program.name("deploysapp").description("DeploysApp CLI").version("0.1.0");
14
+
15
+ function wrap(fn) {
16
+ return async (...args) => { try { await fn(...args); } catch (e) { printErr(e); } };
17
+ }
18
+
19
+ program.command("whoami").description("Show the authenticated account").action(wrap(whoami));
20
+
21
+ program.command("login").description("Authenticate this device")
22
+ .option("--no-open", "don't auto-open the browser")
23
+ .action(wrap((opts) => login({ open: opts.open })));
24
+ program.command("logout").description("Remove stored credentials").action(wrap(logout));
25
+
26
+ program.command("link").description("Bind the current directory to a service")
27
+ .option("--service <id>", "service id (skip the prompt)")
28
+ .action(wrap((opts) => link({ service: opts.service })));
29
+
30
+ program.command("deploy").description("Trigger a redeploy from git and stream the build log")
31
+ .option("--service <id>", "service id")
32
+ .action(wrap((opts) => deploy({ service: opts.service })));
33
+
34
+ program.command("logs").description("Show build or runtime logs")
35
+ .option("--service <id>", "service id")
36
+ .option("-f, --follow", "follow runtime logs")
37
+ .option("--build", "show the latest build log instead of runtime")
38
+ .option("--runtime", "show runtime logs (default)")
39
+ .option("--tail <n>", "lines of runtime history", "200")
40
+ .action(wrap((opts) => logs({ service: opts.service, follow: opts.follow, build: opts.build, runtime: opts.runtime, tail: opts.tail })));
41
+
42
+ program.command("ps").description("List services and their status").action(wrap(() => ps()));
43
+
44
+ const svcOpt = (c) => c.option("--service <id>", "service id");
45
+ svcOpt(program.command("restart").description("Restart a service")).action(wrap((o) => restart(o)));
46
+ svcOpt(program.command("stop").description("Stop a service")).action(wrap((o) => stop(o)));
47
+ svcOpt(program.command("start").description("Start a service")).action(wrap((o) => start(o)));
48
+ svcOpt(program.command("open").description("Open the service URL")).action(wrap((o) => open(o)));
49
+ svcOpt(program.command("scale").description("Set the replica count"))
50
+ .requiredOption("--replicas <n>", "number of replicas")
51
+ .action(wrap((o) => scale(o)));
52
+
53
+ const env = program.command("env").description("Manage environment variables");
54
+ env.command("list").option("--service <id>").action(wrap((o) => envList(o)));
55
+ env.command("get <key>").option("--service <id>").action(wrap((key, o) => envGet({ ...o, key })));
56
+ env.command("set <pair>").option("--service <id>").option("--restart", "restart after setting")
57
+ .action(wrap((pair, o) => envSet({ ...o, pair })));
58
+ env.command("rm <key>").option("--service <id>").option("--restart", "restart after removing")
59
+ .action(wrap((key, o) => envRm({ ...o, key })));
60
+
61
+ const secret = program.command("secret").description("Manage project secrets");
62
+ secret.command("list")
63
+ .description("List secrets for a project")
64
+ .option("--project <id>", "project id")
65
+ .action(wrap((o) => secretList(o)));
66
+ secret.command("attach <name>")
67
+ .description("Expose a project secret to a service as an env var")
68
+ .requiredOption("--as <ENV_KEY>", "environment variable name")
69
+ .option("--project <id>", "project id")
70
+ .option("--service <id>", "service id")
71
+ .action(wrap((name, o) => secretAttach({ ...o, name })));
72
+
73
+ program.parseAsync(process.argv);
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "deploysapp-cli",
3
+ "version": "0.1.0",
4
+ "engines": {
5
+ "node": ">=20"
6
+ },
7
+ "directories": {
8
+ "test": "test"
9
+ },
10
+ "scripts": {
11
+ "test": "vitest run"
12
+ },
13
+ "keywords": [
14
+ "deploysapp",
15
+ "cli",
16
+ "hosting",
17
+ "deployment"
18
+ ],
19
+ "author": "",
20
+ "license": "MIT",
21
+ "description": "Command-line client for the DeploysApp hosting platform — deploy, logs, env, and service control from your terminal.",
22
+ "type": "module",
23
+ "bin": {
24
+ "deploysapp": "bin/deploysapp.js",
25
+ "dsa": "bin/deploysapp.js"
26
+ },
27
+ "dependencies": {
28
+ "commander": "^14.0.3"
29
+ },
30
+ "devDependencies": {
31
+ "vitest": "^4.1.9"
32
+ },
33
+ "files": [
34
+ "bin",
35
+ "src",
36
+ "README.md"
37
+ ]
38
+ }
package/src/client.js ADDED
@@ -0,0 +1,128 @@
1
+ const DEFAULT_BASE_URL = "https://api.deploysapp.com";
2
+
3
+ const ERROR_CODES = {
4
+ UNAUTHORIZED: { retryable: false },
5
+ PERMISSION_DENIED: { retryable: false },
6
+ NOT_FOUND: { retryable: false },
7
+ VALIDATION_ERROR: { retryable: false },
8
+ CONFLICT: { retryable: false },
9
+ RATE_LIMITED: { retryable: true },
10
+ SERVER_ERROR: { retryable: true },
11
+ NETWORK_ERROR: { retryable: true },
12
+ INVALID_CONFIG: { retryable: false },
13
+ UNKNOWN_ERROR: { retryable: false },
14
+ };
15
+
16
+ function codeForStatus(status) {
17
+ if (status === 401) return "UNAUTHORIZED";
18
+ if (status === 403) return "PERMISSION_DENIED";
19
+ if (status === 404) return "NOT_FOUND";
20
+ if (status === 409) return "CONFLICT";
21
+ if (status === 400 || status === 422) return "VALIDATION_ERROR";
22
+ if (status === 429) return "RATE_LIMITED";
23
+ if (status >= 500) return "SERVER_ERROR";
24
+ return "UNKNOWN_ERROR";
25
+ }
26
+
27
+ export class DeploysAppError extends Error {
28
+ constructor({ code, message, status, retryAfter, details }) {
29
+ super(message);
30
+ this.name = "DeploysAppError";
31
+ this.code = code;
32
+ this.status = status ?? null;
33
+ this.retryable = ERROR_CODES[code]?.retryable ?? false;
34
+ this.retryAfter = retryAfter ?? null;
35
+ this.details = details ?? null;
36
+ }
37
+
38
+ toJSON() {
39
+ return {
40
+ error: {
41
+ code: this.code,
42
+ message: this.message,
43
+ status: this.status,
44
+ retryable: this.retryable,
45
+ ...(this.retryAfter ? { retry_after_seconds: this.retryAfter } : {}),
46
+ ...(this.details ? { details: this.details } : {}),
47
+ },
48
+ };
49
+ }
50
+ }
51
+
52
+ export class DeploysAppClient {
53
+ constructor({ apiKey, baseUrl } = {}) {
54
+ this.apiKey = apiKey || process.env.DEPLOYSAPP_API_KEY;
55
+ this.baseUrl = (baseUrl || process.env.DEPLOYSAPP_API_URL || DEFAULT_BASE_URL).replace(/\/$/, "");
56
+ if (!this.apiKey) {
57
+ throw new DeploysAppError({
58
+ code: "INVALID_CONFIG",
59
+ message: "Missing DEPLOYSAPP_API_KEY environment variable. Generate one at https://dashboard.deploysapp.com/dashboard/account → API Keys.",
60
+ });
61
+ }
62
+ if (!/^dsa_/.test(this.apiKey)) {
63
+ throw new DeploysAppError({
64
+ code: "INVALID_CONFIG",
65
+ message: "DEPLOYSAPP_API_KEY must start with 'dsa_' — looks like an invalid key.",
66
+ });
67
+ }
68
+ }
69
+
70
+ async request(method, path, { body, query } = {}) {
71
+ let url = `${this.baseUrl}${path}`;
72
+ if (query && Object.keys(query).length > 0) {
73
+ const qs = new URLSearchParams(query).toString();
74
+ url += `?${qs}`;
75
+ }
76
+
77
+ const headers = {
78
+ "Authorization": `Bearer ${this.apiKey}`,
79
+ "Accept": "application/json",
80
+ "User-Agent": "deploysapp-mcp/0.6.1",
81
+ };
82
+ if (body !== undefined) headers["Content-Type"] = "application/json";
83
+
84
+ let res;
85
+ try {
86
+ res = await fetch(url, {
87
+ method,
88
+ headers,
89
+ body: body !== undefined ? JSON.stringify(body) : undefined,
90
+ });
91
+ } catch (cause) {
92
+ throw new DeploysAppError({
93
+ code: "NETWORK_ERROR",
94
+ message: `Network error contacting DeploysApp API at ${this.baseUrl}: ${cause?.message || cause}`,
95
+ details: { cause: cause?.message || String(cause) },
96
+ });
97
+ }
98
+
99
+ const text = await res.text();
100
+ let data;
101
+ try { data = text ? JSON.parse(text) : null; } catch { data = text; }
102
+
103
+ if (!res.ok) {
104
+ const code = codeForStatus(res.status);
105
+ const apiMessage =
106
+ (data && typeof data === "object" && (data.message || data.error)) ||
107
+ (typeof data === "string" && data) ||
108
+ res.statusText ||
109
+ "Unknown error";
110
+ const retryAfterHeader = res.headers.get("retry-after");
111
+ const retryAfter = retryAfterHeader ? Number(retryAfterHeader) : null;
112
+ throw new DeploysAppError({
113
+ code,
114
+ message: `${method} ${path} → ${res.status} ${code}: ${apiMessage}`,
115
+ status: res.status,
116
+ retryAfter: Number.isFinite(retryAfter) ? retryAfter : null,
117
+ details: typeof data === "object" ? data : { raw: data },
118
+ });
119
+ }
120
+
121
+ return data;
122
+ }
123
+
124
+ get(path, query) { return this.request("GET", path, { query }); }
125
+ post(path, body) { return this.request("POST", path, { body }); }
126
+ patch(path, body) { return this.request("PATCH", path, { body }); }
127
+ delete(path) { return this.request("DELETE", path); }
128
+ }
@@ -0,0 +1,90 @@
1
+ // src/commands/auth.js
2
+ import { execFile } from "child_process";
3
+ import { makeClient, saveConfig, clearConfig, loadConfig } from "../config.js";
4
+
5
+ export async function whoami() {
6
+ const client = makeClient();
7
+ const me = await client.get("/me");
8
+ const email = me?.user?.email || me?.email || "(unknown)";
9
+ console.log(email);
10
+ }
11
+
12
+ const API_URL = () => loadConfig().apiUrl;
13
+
14
+ export function pollForToken(fetchFn, { intervalMs = 5000, maxMs = 600000 } = {}) {
15
+ const deadline = Date.now() + maxMs;
16
+ return new Promise((resolve, reject) => {
17
+ const tick = async () => {
18
+ let r;
19
+ try {
20
+ r = await fetchFn();
21
+ } catch (e) {
22
+ // Transient network error mid-poll — retry until the deadline instead of
23
+ // failing the whole login on a single blip.
24
+ if (Date.now() > deadline) {
25
+ const err = new Error("Login timed out.");
26
+ err.exitCode = 3;
27
+ return reject(err);
28
+ }
29
+ return setTimeout(tick, intervalMs);
30
+ }
31
+ if (r.status === 200 && r.body?.api_key) return resolve(r.body.api_key);
32
+ const err = r.body?.error;
33
+ if (err === "authorization_pending") {
34
+ if (Date.now() > deadline) {
35
+ const e = new Error("Login timed out.");
36
+ e.exitCode = 3;
37
+ return reject(e);
38
+ }
39
+ return setTimeout(tick, intervalMs);
40
+ }
41
+ if (err === "expired_token") {
42
+ const e = new Error("Code expired — run `deploysapp login` again.");
43
+ e.exitCode = 3;
44
+ return reject(e);
45
+ }
46
+ if (err === "access_denied") return reject(new Error("Login was denied."));
47
+ return reject(new Error(err || "Login failed."));
48
+ };
49
+ tick();
50
+ });
51
+ }
52
+
53
+ function openBrowser(url) {
54
+ if (process.platform === "darwin") return execFile("open", [url], () => {});
55
+ if (process.platform === "win32") return execFile("cmd", ["/c", "start", "", url], () => {});
56
+ return execFile("xdg-open", [url], () => {});
57
+ }
58
+
59
+ export async function login({ open = true } = {}) {
60
+ const base = API_URL();
61
+ const startRes = await fetch(`${base}/cli/device/code`, { method: "POST" });
62
+ if (!startRes.ok) {
63
+ const err = new Error(`Could not start login (server returned ${startRes.status}). Try again later.`);
64
+ err.exitCode = 3;
65
+ throw err;
66
+ }
67
+ const start = await startRes.json();
68
+ console.log(`\n To authorize this device, visit:\n ${start.verification_uri}\n and enter the code:\n\n ${start.user_code}\n`);
69
+ if (open) openBrowser(start.verification_uri);
70
+
71
+ const apiKey = await pollForToken(
72
+ async () => {
73
+ const res = await fetch(`${base}/cli/device/token`, {
74
+ method: "POST",
75
+ headers: { "Content-Type": "application/json" },
76
+ body: JSON.stringify({ device_code: start.device_code }),
77
+ });
78
+ return { status: res.status, body: await res.json().catch(() => ({})) };
79
+ },
80
+ { intervalMs: (start.interval || 5) * 1000, maxMs: (start.expires_in || 600) * 1000 }
81
+ );
82
+
83
+ saveConfig({ apiKey });
84
+ console.log("✓ Logged in.");
85
+ }
86
+
87
+ export async function logout() {
88
+ clearConfig();
89
+ console.log("✓ Logged out.");
90
+ }
@@ -0,0 +1,33 @@
1
+ // src/commands/deploy.js
2
+ import { makeClient } from "../config.js";
3
+ import { resolveServiceId } from "../project.js";
4
+
5
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
6
+
7
+ export async function streamBuild(client, buildId, { sleep = (ms) => wait(ms), onLine = (l) => console.log(l), maxMs = 3600000, now = () => Date.now() } = {}) {
8
+ let cursor = 0;
9
+ const deadline = now() + maxMs;
10
+ // eslint-disable-next-line no-constant-condition
11
+ while (true) {
12
+ const page = await client.get(`/builds/${encodeURIComponent(buildId)}/logs?since=${cursor}`);
13
+ for (const line of page.lines || []) onLine(line);
14
+ if (typeof page.nextCursor === "number") cursor = page.nextCursor;
15
+ if (page.done) return page.build?.status || "unknown";
16
+ if (now() > deadline) {
17
+ onLine("[deploy] Stopped tailing after timeout; the build may still be running. Check the dashboard.");
18
+ return "timeout";
19
+ }
20
+ await sleep(2000);
21
+ }
22
+ }
23
+
24
+ export async function deploy({ service } = {}) {
25
+ const client = makeClient();
26
+ const serviceId = resolveServiceId({ flag: service });
27
+ const { build } = await client.post(`/services/${encodeURIComponent(serviceId)}/redeploy`);
28
+ if (!build?.id) { const e = new Error("Redeploy did not return a build. Check the service has a connected git repo."); e.exitCode = 3; throw e; }
29
+ console.log(`Build ${build.id} queued. Streaming logs…\n`);
30
+ const status = await streamBuild(client, build.id);
31
+ if (status === "success") { console.log("\n✓ Deploy succeeded."); }
32
+ else { console.error(`\n✗ Deploy ${status}.`); process.exitCode = 1; }
33
+ }
@@ -0,0 +1,54 @@
1
+ // src/commands/env.js
2
+ import { makeClient } from "../config.js";
3
+ import { resolveServiceId } from "../project.js";
4
+
5
+ export function parseKeyValue(pair) {
6
+ const idx = (pair || "").indexOf("=");
7
+ if (idx <= 0) { const e = new Error("Expected KEY=VALUE."); e.exitCode = 2; throw e; }
8
+ return { key: pair.slice(0, idx), value: pair.slice(idx + 1) };
9
+ }
10
+
11
+ async function fetchEnv(client, id) {
12
+ const data = await client.get(`/services/${encodeURIComponent(id)}/env`);
13
+ return data.env || data.envVars || (Array.isArray(data) ? data : []);
14
+ }
15
+
16
+ export async function envList({ service } = {}) {
17
+ const client = makeClient();
18
+ const id = resolveServiceId({ flag: service });
19
+ const env = await fetchEnv(client, id);
20
+ if (!env.length) { console.log("No env vars set."); return; }
21
+ for (const e of env) console.log(`${e.key}=${e.value}`);
22
+ }
23
+
24
+ export async function envGet({ service, key } = {}) {
25
+ const client = makeClient();
26
+ const id = resolveServiceId({ flag: service });
27
+ const env = await fetchEnv(client, id);
28
+ const hit = env.find((e) => e.key === key);
29
+ if (!hit) { const e = new Error(`No env var named ${key}.`); e.exitCode = 2; throw e; }
30
+ console.log(hit.value);
31
+ }
32
+
33
+ export async function envSet({ service, pair, restart } = {}) {
34
+ const client = makeClient();
35
+ const id = resolveServiceId({ flag: service });
36
+ const { key, value } = parseKeyValue(pair);
37
+ const env = await fetchEnv(client, id);
38
+ const existing = env.find((e) => e.key === key);
39
+ const q = restart ? "?restart=true" : "";
40
+ if (existing) await client.patch(`/services/${encodeURIComponent(id)}/env/${existing.id}${q}`, { key, value });
41
+ else await client.post(`/services/${encodeURIComponent(id)}/env${q}`, { key, value });
42
+ console.log(`✓ Set ${key}${restart ? " (restarted)" : ""}.`);
43
+ }
44
+
45
+ export async function envRm({ service, key, restart } = {}) {
46
+ const client = makeClient();
47
+ const id = resolveServiceId({ flag: service });
48
+ const env = await fetchEnv(client, id);
49
+ const existing = env.find((e) => e.key === key);
50
+ if (!existing) { const e = new Error(`No env var named ${key}.`); e.exitCode = 2; throw e; }
51
+ const q = restart ? "?restart=true" : "";
52
+ await client.delete(`/services/${encodeURIComponent(id)}/env/${existing.id}${q}`);
53
+ console.log(`✓ Removed ${key}.`);
54
+ }
@@ -0,0 +1,24 @@
1
+ import { createInterface } from "readline/promises";
2
+ import { makeClient } from "../config.js";
3
+ import { saveProject } from "../project.js";
4
+
5
+ export async function link({ service } = {}) {
6
+ const client = makeClient();
7
+ const { services } = await client.get("/services");
8
+ if (!services?.length) { console.log("No services found. Create one in the dashboard first."); return; }
9
+
10
+ let chosen;
11
+ if (service) {
12
+ chosen = services.find((s) => s.id === service);
13
+ if (!chosen) { const e = new Error(`Service ${service} not found.`); e.exitCode = 2; throw e; }
14
+ } else {
15
+ services.forEach((s, i) => console.log(` [${i + 1}] ${s.name} (${s.id}) ${s.host || ""}`));
16
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
17
+ const answer = await rl.question("Select a service: ");
18
+ rl.close();
19
+ chosen = services[Number(answer) - 1];
20
+ if (!chosen) { const e = new Error("Invalid selection."); e.exitCode = 2; throw e; }
21
+ }
22
+ saveProject({ serviceId: chosen.id, projectId: chosen.projectId });
23
+ console.log(`✓ Linked to ${chosen.name} (${chosen.id}). Wrote .deploysapp.json`);
24
+ }
@@ -0,0 +1,34 @@
1
+ // src/commands/logs.js
2
+ import { makeClient } from "../config.js";
3
+ import { resolveServiceId } from "../project.js";
4
+ import { streamBuild } from "./deploy.js";
5
+
6
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
7
+
8
+ export async function tailRuntime(client, serviceId, { follow = false, tail = 200, sleep = (ms) => wait(ms), onLine = (l) => console.log(l) } = {}) {
9
+ let since = 0;
10
+ const first = await client.get(`/services/${encodeURIComponent(serviceId)}/logs?tail=${tail}`);
11
+ for (const l of first.lines || []) onLine(l);
12
+ since = first.ts ?? since;
13
+ if (!follow) return;
14
+ // eslint-disable-next-line no-constant-condition
15
+ while (true) {
16
+ await sleep(2000);
17
+ const page = await client.get(`/services/${encodeURIComponent(serviceId)}/logs?since=${since}`);
18
+ for (const l of page.lines || []) onLine(l);
19
+ since = page.ts ?? since;
20
+ }
21
+ }
22
+
23
+ export async function logs({ service, follow, build, runtime, tail } = {}) {
24
+ const client = makeClient();
25
+ const serviceId = resolveServiceId({ flag: service });
26
+ const wantBuild = build && !runtime;
27
+ if (wantBuild) {
28
+ const { builds } = await client.get(`/services/${encodeURIComponent(serviceId)}/builds`);
29
+ if (!builds?.length) { console.log("No builds yet."); return; }
30
+ await streamBuild(client, builds[0].id);
31
+ return;
32
+ }
33
+ await tailRuntime(client, serviceId, { follow: !!follow, tail: Number(tail) || 200 });
34
+ }
@@ -0,0 +1,43 @@
1
+ // src/commands/secret.js
2
+ import { makeClient } from "../config.js";
3
+ import { resolveServiceId, loadProject } from "../project.js";
4
+
5
+ export function resolveProjectId({ flag } = {}) {
6
+ if (flag) return flag;
7
+ const p = loadProject();
8
+ if (p?.projectId) return p.projectId;
9
+ const err = new Error("No project selected. Pass --project <id> or run `deploysapp link`.");
10
+ err.exitCode = 2;
11
+ throw err;
12
+ }
13
+
14
+ async function fetchSecrets(client, projectId) {
15
+ const data = await client.get(`/projects/${encodeURIComponent(projectId)}/secrets`);
16
+ return data.secrets || (Array.isArray(data) ? data : []);
17
+ }
18
+
19
+ export async function secretList({ project } = {}) {
20
+ const client = makeClient();
21
+ const projectId = resolveProjectId({ flag: project });
22
+ const secrets = await fetchSecrets(client, projectId);
23
+ if (!secrets.length) { console.log("No secrets."); return; }
24
+ for (const s of secrets) console.log(`${s.name} ${s.id}`);
25
+ }
26
+
27
+ export async function secretAttach({ project, service, name, as: envKey } = {}) {
28
+ const client = makeClient();
29
+ const projectId = resolveProjectId({ flag: project });
30
+ const serviceId = resolveServiceId({ flag: service });
31
+ const secrets = await fetchSecrets(client, projectId);
32
+ const secret = secrets.find((s) => s.name === name || s.id === name);
33
+ if (!secret) {
34
+ const err = new Error(`No secret named or with id "${name}".`);
35
+ err.exitCode = 2;
36
+ throw err;
37
+ }
38
+ await client.post(`/services/${encodeURIComponent(serviceId)}/secret-refs`, {
39
+ secretId: secret.id,
40
+ envKey,
41
+ });
42
+ console.log(`✓ Attached secret "${secret.name}" as ${envKey}.`);
43
+ }
@@ -0,0 +1,49 @@
1
+ // src/commands/service.js
2
+ import { execFile } from "child_process";
3
+ import { makeClient } from "../config.js";
4
+ import { resolveServiceId } from "../project.js";
5
+
6
+ export function formatServicesTable(services) {
7
+ if (!services?.length) return "No services found.";
8
+ const rows = services.map((s) => [s.name || "", s.status || "", s.host || ""]);
9
+ const widths = [0, 1, 2].map((c) => Math.max(...rows.map((r) => r[c].length), ["NAME", "STATUS", "HOST"][c].length));
10
+ const fmt = (r) => r.map((v, c) => v.padEnd(widths[c])).join(" ");
11
+ return [fmt(["NAME", "STATUS", "HOST"]), ...rows.map(fmt)].join("\n");
12
+ }
13
+
14
+ export async function ps() {
15
+ const client = makeClient();
16
+ const { services } = await client.get("/services");
17
+ console.log(formatServicesTable(services));
18
+ }
19
+
20
+ async function lifecycle(action, service) {
21
+ const client = makeClient();
22
+ const id = resolveServiceId({ flag: service });
23
+ await client.post(`/services/${encodeURIComponent(id)}/${action}`);
24
+ console.log(`✓ ${action} requested for ${id}.`);
25
+ }
26
+ export const restart = ({ service } = {}) => lifecycle("restart", service);
27
+ export const stop = ({ service } = {}) => lifecycle("stop", service);
28
+ export const start = ({ service } = {}) => lifecycle("start", service);
29
+
30
+ export async function scale({ service, replicas } = {}) {
31
+ const client = makeClient();
32
+ const id = resolveServiceId({ flag: service });
33
+ const n = Number(replicas);
34
+ if (!Number.isInteger(n) || n < 1 || n > 10) { const e = new Error("--replicas must be 1-10."); e.exitCode = 2; throw e; }
35
+ await client.patch(`/services/${encodeURIComponent(id)}/scale`, { replicas: n });
36
+ console.log(`✓ Scaled ${id} to ${n} replica(s).`);
37
+ }
38
+
39
+ export async function open({ service } = {}) {
40
+ const client = makeClient();
41
+ const id = resolveServiceId({ flag: service });
42
+ const { service: svc } = await client.get(`/services/${encodeURIComponent(id)}`);
43
+ const url = svc?.host ? `https://${svc.host}` : null;
44
+ if (!url) { const e = new Error("Service has no public host."); e.exitCode = 2; throw e; }
45
+ console.log(url);
46
+ if (process.platform === "darwin") return execFile("open", [url], () => {});
47
+ if (process.platform === "win32") return execFile("cmd", ["/c", "start", "", url], () => {});
48
+ return execFile("xdg-open", [url], () => {});
49
+ }
package/src/config.js ADDED
@@ -0,0 +1,41 @@
1
+ // src/config.js
2
+ import { homedir } from "os";
3
+ import { join } from "path";
4
+ import { mkdirSync, readFileSync, writeFileSync, chmodSync, rmSync, existsSync } from "fs";
5
+ import { DeploysAppClient } from "./client.js";
6
+
7
+ function configHome() {
8
+ return process.env.DEPLOYSAPP_CONFIG_HOME || join(homedir(), ".deploysapp");
9
+ }
10
+ function configPath() { return join(configHome(), "config.json"); }
11
+
12
+ export function saveConfig({ apiKey }) {
13
+ mkdirSync(configHome(), { recursive: true });
14
+ writeFileSync(configPath(), JSON.stringify({ apiKey }, null, 2), { mode: 0o600 });
15
+ chmodSync(configPath(), 0o600);
16
+ }
17
+
18
+ export function loadConfig() {
19
+ const fileKey = (() => {
20
+ try { return JSON.parse(readFileSync(configPath(), "utf8")).apiKey || null; }
21
+ catch { return null; }
22
+ })();
23
+ return {
24
+ apiKey: process.env.DEPLOYSAPP_API_KEY || fileKey,
25
+ apiUrl: process.env.DEPLOYSAPP_API_URL || "https://api.deploysapp.com",
26
+ };
27
+ }
28
+
29
+ export function clearConfig() {
30
+ if (existsSync(configPath())) rmSync(configPath());
31
+ }
32
+
33
+ export function makeClient() {
34
+ const { apiKey, apiUrl } = loadConfig();
35
+ if (!apiKey) {
36
+ const err = new Error("Not logged in. Run `deploysapp login`.");
37
+ err.exitCode = 1;
38
+ throw err;
39
+ }
40
+ return new DeploysAppClient({ apiKey, baseUrl: apiUrl });
41
+ }
package/src/output.js ADDED
@@ -0,0 +1,7 @@
1
+ // src/output.js
2
+ export function printJson(obj) { console.log(JSON.stringify(obj, null, 2)); }
3
+ export function printErr(err) {
4
+ const code = err?.exitCode ?? (err?.code === "UNAUTHORIZED" ? 1 : err?.retryable ? 3 : 2);
5
+ console.error(`error: ${err?.message || err}`);
6
+ process.exitCode = code;
7
+ }
package/src/project.js ADDED
@@ -0,0 +1,20 @@
1
+ import { join } from "path";
2
+ import { readFileSync, writeFileSync } from "fs";
3
+
4
+ const FILE = ".deploysapp.json";
5
+ function path() { return join(process.cwd(), FILE); }
6
+
7
+ export function saveProject({ serviceId, projectId }) {
8
+ writeFileSync(path(), JSON.stringify({ serviceId, projectId }, null, 2));
9
+ }
10
+ export function loadProject() {
11
+ try { return JSON.parse(readFileSync(path(), "utf8")); } catch { return null; }
12
+ }
13
+ export function resolveServiceId({ flag } = {}) {
14
+ if (flag) return flag;
15
+ const p = loadProject();
16
+ if (p?.serviceId) return p.serviceId;
17
+ const err = new Error("No service selected. Pass --service <id> or run `deploysapp link`.");
18
+ err.exitCode = 2;
19
+ throw err;
20
+ }