@suronai/cli 0.1.35 → 0.1.37

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/package.json CHANGED
@@ -1,23 +1,23 @@
1
- {
2
- "name": "@suronai/cli",
3
- "version": "0.1.35",
4
- "description": "CLI for Suron suron login, init, whoami, recover",
5
- "type": "module",
6
- "bin": {
7
- "suron": "./src/index.js"
8
- },
9
- "files": [
10
- "src"
11
- ],
12
- "scripts": {
13
- "build": "node --check src/index.js src/commands/login.js src/commands/init.js src/commands/whoami.js src/commands/recover.js src/utils/config.js src/utils/dotenvx.js src/utils/colors.js",
14
- "lint": "node --check src/index.js src/commands/login.js src/commands/init.js src/commands/whoami.js src/commands/recover.js src/utils/config.js src/utils/dotenvx.js src/utils/colors.js"
15
- },
16
- "dependencies": {
17
- "@dotenvx/dotenvx": "latest",
18
- "commander": "latest"
19
- },
20
- "engines": {
21
- "node": ">=18.0.0"
22
- }
23
- }
1
+ {
2
+ "name": "@suronai/cli",
3
+ "version": "0.1.37",
4
+ "description": "Suron CLI — secrets delivery",
5
+ "type": "module",
6
+ "bin": {
7
+ "suron": "./src/index.js"
8
+ },
9
+ "files": [
10
+ "src"
11
+ ],
12
+ "scripts": {
13
+ "start": "bun src/index.js",
14
+ "dev": "bun --watch src/index.js"
15
+ },
16
+ "dependencies": {
17
+ "@clack/prompts": "^1.3.0",
18
+ "commander": "^14.0.3"
19
+ },
20
+ "engines": {
21
+ "bun": ">=1.0.0"
22
+ }
23
+ }
@@ -0,0 +1,237 @@
1
+ import { Command } from "commander";
2
+ import { intro, outro, spinner, select, text, isCancel } from "@clack/prompts";
3
+ import { existsSync, readFileSync, writeFileSync } from "fs";
4
+ import { join } from "path";
5
+ import { exec } from "child_process";
6
+ import ui from "./utils/colors.js";
7
+ import { getToken, requireToken, saveConfig, clearToken } from "./utils/config.js";
8
+ import { api, BASE_URL } from "./utils/api.js";
9
+
10
+ function cancel(msg) {
11
+ console.log(ui.blank());
12
+ console.log(ui.errBlock(msg));
13
+ console.log(ui.blank());
14
+ process.exit(1);
15
+ }
16
+
17
+ export const loginCommand = new Command("login")
18
+ .description("Authenticate via browser")
19
+ .option("--force", "Re-authenticate even if already logged in")
20
+ .action(async (opts) => {
21
+ const existing = getToken();
22
+ if (existing && !opts.force) {
23
+ console.log(ui.blank());
24
+ console.log(ui.errBlock("already logged in", "use --force to re-authenticate"));
25
+ console.log(ui.blank());
26
+ process.exit(1);
27
+ }
28
+
29
+ console.log(ui.logo());
30
+ console.log(ui.blank());
31
+ console.log(ui.hr());
32
+ console.log(ui.blank());
33
+
34
+ const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
35
+ let code = "";
36
+ for (let i = 0; i < 6; i++) code += chars[Math.floor(Math.random() * chars.length)];
37
+
38
+ await api("/cli/auth-code", {
39
+ method: "POST",
40
+ body: JSON.stringify({ code }),
41
+ });
42
+
43
+ const loginUrl = `${BASE_URL}/clilogin?code=${code}`;
44
+
45
+ console.log(ui.infoBlock("Opening browser to complete login..."));
46
+ console.log(ui.blank());
47
+ console.log(ui.kv("URL", loginUrl, 6));
48
+ console.log(ui.blank());
49
+
50
+ exec(`open "${loginUrl}" 2>/dev/null || xdg-open "${loginUrl}" 2>/dev/null || cmd /c start "" "${loginUrl}" 2>/dev/null`);
51
+
52
+ const s = spinner();
53
+ s.start("Waiting for approval");
54
+
55
+ const deadline = Date.now() + 300_000;
56
+ let token = null;
57
+
58
+ while (Date.now() < deadline) {
59
+ await new Promise(r => setTimeout(r, 2000));
60
+ try {
61
+ const result = await api(`/cli/auth-code/status?code=${code}`);
62
+ if (result.status === "approved" && result.token) {
63
+ token = result.token;
64
+ break;
65
+ }
66
+ } catch {}
67
+ }
68
+
69
+ s.stop(token ? "Approved" : "Timed out");
70
+
71
+ if (!token) {
72
+ console.log(ui.blank());
73
+ console.log(ui.errBlock("login timed out", "try again"));
74
+ process.exit(1);
75
+ }
76
+
77
+ saveConfig({ token });
78
+
79
+ console.log(ui.blank());
80
+ console.log(ui.step("done", "logged in"));
81
+ console.log(ui.step("done", "saved ~/.suron-config"));
82
+ console.log(ui.blank());
83
+ console.log(ui.hr());
84
+ console.log(ui.blank());
85
+ console.log(ui.infoBlock("next › cd your-project && " + ui.code("suron init")));
86
+ console.log(ui.blank());
87
+ });
88
+
89
+ export const logoutCommand = new Command("logout")
90
+ .description("Clear local session token")
91
+ .action(async () => {
92
+ clearToken();
93
+ try {
94
+ await api("/auth/logout", { method: "POST" });
95
+ } catch {}
96
+ console.log(ui.blank());
97
+ console.log(ui.okBlock("logged out"));
98
+ console.log(ui.blank());
99
+ });
100
+
101
+ export const initCommand = new Command("init")
102
+ .description("Initialize Suron in this project")
103
+ .action(async () => {
104
+ const token = requireToken();
105
+
106
+ if (existsSync(".suron.json")) {
107
+ console.log(ui.blank());
108
+ console.log(ui.errBlock(".suron.json already exists", "use suron up to push a new version"));
109
+ console.log(ui.blank());
110
+ process.exit(1);
111
+ }
112
+
113
+ if (!existsSync(".env")) {
114
+ console.log(ui.blank());
115
+ console.log(ui.errBlock(".env not found", "create a .env file first"));
116
+ console.log(ui.blank());
117
+ process.exit(1);
118
+ }
119
+
120
+ console.log(ui.logo());
121
+ console.log(ui.blank());
122
+ console.log(ui.hr());
123
+ console.log(ui.blank());
124
+
125
+ const mode = await select({
126
+ message: "How do you want to link this project?",
127
+ options: [
128
+ { value: "new", label: "Create new app" },
129
+ { value: "existing", label: "Link to existing app" },
130
+ ],
131
+ });
132
+
133
+ if (isCancel(mode)) cancel("cancelled");
134
+
135
+ let app_id, app_name;
136
+
137
+ if (mode === "new") {
138
+ const name = await text({
139
+ message: "App name (alphanumeric)",
140
+ validate: v => /^[a-zA-Z0-9]+$/.test(v) ? undefined : "Alphanumeric only",
141
+ });
142
+ if (isCancel(name)) cancel("cancelled");
143
+
144
+ const result = await api("/apps", {
145
+ method: "POST",
146
+ body: JSON.stringify({ name }),
147
+ }).catch(err => cancel(err.message));
148
+
149
+ app_id = result.app_id;
150
+ app_name = result.name;
151
+ } else {
152
+ const apps = await api("/apps").catch(err => cancel(err.message));
153
+
154
+ if (!apps.length) {
155
+ console.log(ui.blank());
156
+ console.log(ui.errBlock("no apps found", "use 'Create new app' instead"));
157
+ process.exit(1);
158
+ }
159
+
160
+ const chosen = await select({
161
+ message: "Select an app",
162
+ options: apps.map(a => ({ value: a.app_id, label: a.name })),
163
+ });
164
+ if (isCancel(chosen)) cancel("cancelled");
165
+
166
+ const found = apps.find(a => a.app_id === chosen);
167
+ app_id = found.app_id;
168
+ app_name = found.name;
169
+ }
170
+
171
+ const env_plaintext = readFileSync(".env", "utf-8");
172
+
173
+ const s = spinner();
174
+ s.start("Pushing v1");
175
+
176
+ const result = await api(`/apps/${app_id}/versions`, {
177
+ method: "POST",
178
+ body: JSON.stringify({ env_plaintext }),
179
+ }).catch(err => { s.stop("failed"); cancel(err.message); });
180
+
181
+ s.stop("Done");
182
+
183
+ const suronJson = { app_name, app_id, version: result.version };
184
+ writeFileSync(".suron.json", JSON.stringify(suronJson, null, 2) + "\n");
185
+
186
+ console.log(ui.blank());
187
+ console.log(ui.step("done", "encrypted and uploaded .env"));
188
+ console.log(ui.step("done", `version ${result.version}`));
189
+ console.log(ui.step("done", "wrote .suron.json"));
190
+ console.log(ui.blank());
191
+ console.log(ui.hr());
192
+ console.log(ui.blank());
193
+ console.log(ui.infoBlock("add .suron.json to git · add .env to .gitignore"));
194
+ console.log(ui.blank());
195
+ });
196
+
197
+ export const upCommand = new Command("up")
198
+ .description("Push a new version of .env")
199
+ .action(async () => {
200
+ requireToken();
201
+
202
+ if (!existsSync(".suron.json")) {
203
+ console.log(ui.blank());
204
+ console.log(ui.errBlock(".suron.json not found", "run: suron init"));
205
+ console.log(ui.blank());
206
+ process.exit(1);
207
+ }
208
+
209
+ if (!existsSync(".env")) {
210
+ console.log(ui.blank());
211
+ console.log(ui.errBlock(".env not found"));
212
+ console.log(ui.blank());
213
+ process.exit(1);
214
+ }
215
+
216
+ const suronJson = JSON.parse(readFileSync(".suron.json", "utf-8"));
217
+ const { app_id, app_name } = suronJson;
218
+
219
+ const env_plaintext = readFileSync(".env", "utf-8");
220
+
221
+ const s = spinner();
222
+ s.start("Pushing new version");
223
+
224
+ const result = await api(`/apps/${app_id}/versions`, {
225
+ method: "POST",
226
+ body: JSON.stringify({ env_plaintext }),
227
+ }).catch(err => { s.stop("failed"); cancel(err.message); });
228
+
229
+ s.stop("Done");
230
+
231
+ suronJson.version = result.version;
232
+ writeFileSync(".suron.json", JSON.stringify(suronJson, null, 2) + "\n");
233
+
234
+ console.log(ui.blank());
235
+ console.log(ui.step("done", `version ${result.version} pushed`));
236
+ console.log(ui.blank());
237
+ });
package/src/index.js CHANGED
@@ -2,12 +2,9 @@
2
2
 
3
3
  import { Command } from "commander";
4
4
  import { createRequire } from "module";
5
- import { loginCommand } from "./commands/login.js";
6
- import { initCommand } from "./commands/init.js";
7
- import { whoamiCommand } from "./commands/whoami.js";
8
- import { recoverCommand } from "./commands/recover.js";
5
+ import { loginCommand, logoutCommand, initCommand, upCommand } from "./commands.js";
9
6
 
10
- const require = createRequire(import.meta.url);
7
+ const require = createRequire(import.meta.url);
11
8
  const { version } = require("../package.json");
12
9
 
13
10
  const program = new Command();
@@ -18,8 +15,8 @@ program
18
15
  .version(version);
19
16
 
20
17
  program.addCommand(loginCommand);
18
+ program.addCommand(logoutCommand);
21
19
  program.addCommand(initCommand);
22
- program.addCommand(whoamiCommand);
23
- program.addCommand(recoverCommand);
20
+ program.addCommand(upCommand);
24
21
 
25
22
  program.parse(process.argv);
@@ -0,0 +1,20 @@
1
+ import { getToken } from "./config.js";
2
+
3
+ const BASE_URL = "https://suronai.com";
4
+
5
+ export async function api(path, options = {}) {
6
+ const token = getToken();
7
+ const headers = { "Content-Type": "application/json", ...options.headers };
8
+ if (token) headers["Authorization"] = `Bearer ${token}`;
9
+
10
+ const res = await fetch(`${BASE_URL}${path}`, { ...options, headers });
11
+
12
+ if (!res.ok) {
13
+ const data = await res.json().catch(() => ({}));
14
+ throw new Error(data.error ?? `HTTP ${res.status}`);
15
+ }
16
+
17
+ return res.json();
18
+ }
19
+
20
+ export { BASE_URL };
@@ -1,11 +1,181 @@
1
- // Minimal ANSI helpers — no deps, works in any Node.js
2
- export const c = {
3
- bold: (s) => `\x1b[1m${s}\x1b[0m`,
4
- dim: (s) => `\x1b[2m${s}\x1b[0m`,
5
- green: (s) => `\x1b[32m${s}\x1b[0m`,
6
- red: (s) => `\x1b[31m${s}\x1b[0m`,
7
- yellow: (s) => `\x1b[33m${s}\x1b[0m`,
8
- cyan: (s) => `\x1b[36m${s}\x1b[0m`,
1
+ /**
2
+ * SURON Terminal Design System
3
+ *
4
+ * Aesthetic: Dieter Rams × Teenage Engineering
5
+ * Principle: Less, but better. Every character earns its place.
6
+ *
7
+ * Palette:
8
+ * Amber — primary signal, values, focus #FFB347 → \x1b[38;5;214m
9
+ * Warm white — labels, structural text \x1b[97m
10
+ * Slate — secondary / dim \x1b[2m
11
+ * Green — success / done \x1b[38;5;71m
12
+ * Red — error / fail \x1b[38;5;167m
13
+ * No blue. No purple. No cyan.
14
+ *
15
+ * Grid: all content lives at a 2-space left indent.
16
+ * Rule width: 58 chars of content (60 total with indent).
17
+ */
18
+
19
+ const ESC = "\x1b[";
20
+ const R = "\x1b[0m";
21
+
22
+ // Core palette
23
+ const amber = (s) => `${ESC}38;5;214m${s}${R}`;
24
+ const warm = (s) => `${ESC}97m${s}${R}`;
25
+ const slate = (s) => `${ESC}2m${s}${R}`;
26
+ const green = (s) => `${ESC}38;5;71m${s}${R}`;
27
+ const red = (s) => `${ESC}38;5;167m${s}${R}`;
28
+ const bold = (s) => `${ESC}1m${s}${R}`;
29
+ const italic = (s) => `${ESC}3m${s}${R}`;
30
+
31
+ // Semantic aliases
32
+ const value = amber; // user-supplied values, app names, URLs
33
+ const label = slate; // column headers, static labels
34
+ const ok = green; // success states
35
+ const fail = red; // error states
36
+ const em = warm; // emphasis within a line
37
+
38
+ // ── Structural primitives ────────────────────────────────────────────────────
39
+
40
+ const INDENT = " ";
41
+ const COL_W = 58;
42
+
43
+ /** Horizontal rule — thin, full-width */
44
+ const hr = () => INDENT + slate("─".repeat(COL_W));
45
+
46
+ /** Thick rule — section separator */
47
+ const hrThick = () => INDENT + slate("━".repeat(COL_W));
48
+
49
+ /** Blank line */
50
+ const blank = () => "";
51
+
52
+ /** Section header — sparse, uppercase, no decoration */
53
+ const header = (text) =>
54
+ INDENT + bold(warm(text.toUpperCase()));
55
+
56
+ /**
57
+ * Key-value row — strict two-column layout
58
+ * key is left-padded to `keyWidth` (default 14), value is amber
59
+ * @param {string} key
60
+ * @param {string} val
61
+ * @param {number} [keyWidth=14]
62
+ */
63
+ const kv = (key, val, keyWidth = 14) =>
64
+ INDENT + label(key.padEnd(keyWidth)) + value(val);
65
+
66
+ /**
67
+ * Status row — used in step lists
68
+ * States: "pending" | "run" | "done" | "skip" | "fail"
69
+ * @param {"pending"|"run"|"done"|"skip"|"fail"} state
70
+ * @param {string} text
71
+ * @param {string} [note]
72
+ */
73
+ const STATUS_GLYPHS = {
74
+ pending: slate("·"),
75
+ run: amber("▶"),
76
+ done: green("✔"),
77
+ skip: slate("○"),
78
+ fail: red("✘"),
79
+ };
80
+ const STATUS_LABELS = {
81
+ pending: slate("···"),
82
+ run: amber("RUN"),
83
+ done: green("OK "),
84
+ skip: slate("SKP"),
85
+ fail: red("ERR"),
86
+ };
87
+
88
+ const step = (state, text, note) => {
89
+ const glyph = STATUS_GLYPHS[state] ?? STATUS_GLYPHS.pending;
90
+ const status = STATUS_LABELS[state] ?? STATUS_LABELS.pending;
91
+ const body = state === "fail" ? red(text)
92
+ : state === "done" ? slate(text)
93
+ : state === "skip" ? slate(text)
94
+ : state === "run" ? warm(text)
95
+ : slate(text);
96
+ const tail = note ? " " + slate(italic(note)) : "";
97
+ return INDENT + glyph + " " + status + " " + body + tail;
98
+ };
99
+
100
+ /**
101
+ * Inline prompt indicator — shown before user input
102
+ * @param {string} question
103
+ * @returns {string}
104
+ */
105
+ const promptLine = (question) =>
106
+ INDENT + amber("▸") + " " + warm(question) + " " + slate("›") + " ";
107
+
108
+ /**
109
+ * Error block — tight, no wasted space
110
+ * @param {string} msg
111
+ * @param {string} [hint]
112
+ */
113
+ const errBlock = (msg, hint) => {
114
+ const lines = [INDENT + red("✘") + " " + red(msg)];
115
+ if (hint) lines.push(INDENT + " " + slate(hint));
116
+ return lines.join("\n");
9
117
  };
10
118
 
11
- export default c;
119
+ /**
120
+ * Success block
121
+ * @param {string} msg
122
+ */
123
+ const okBlock = (msg) =>
124
+ INDENT + green("✔") + " " + ok(msg);
125
+
126
+ /**
127
+ * Info block (neutral)
128
+ * @param {string} msg
129
+ */
130
+ const infoBlock = (msg) =>
131
+ INDENT + slate("·") + " " + slate(msg);
132
+
133
+ /**
134
+ * Code / path snippet — amber, stencil feel
135
+ * @param {string} text
136
+ */
137
+ const code = (text) =>
138
+ amber(text);
139
+
140
+ /**
141
+ * Diff line — used in entry-point patching
142
+ * @param {"remove"|"add"|"unknown"} type
143
+ * @param {string} text
144
+ */
145
+ const diff = (type, text) => {
146
+ if (type === "remove") return INDENT + " " + red("−") + " " + slate(text);
147
+ if (type === "add") return INDENT + " " + green("+") + " " + amber(text);
148
+ return INDENT + " " + slate("?") + " " + slate("(manual update required)");
149
+ };
150
+
151
+ /**
152
+ * Logo / wordmark — sparse, dot-matrix feel
153
+ * Only shown once per session (login/init headers).
154
+ */
155
+ const logo = () => [
156
+ blank(),
157
+ INDENT + bold(warm("SURON")) + " " + slate("· secrets delivery"),
158
+ ].join("\n");
159
+
160
+ /**
161
+ * Final "ready" block shown at end of init/encrypt
162
+ * @param {Array<{label:string, note:string}>} facts
163
+ */
164
+ const readyBlock = (facts) => {
165
+ const lines = facts.map(f =>
166
+ INDENT + green("·") + " " + label(f.label.padEnd(16)) + slate(f.note)
167
+ );
168
+ lines.push(blank());
169
+ lines.push(INDENT + green("▶") + " " + bold(ok("READY")));
170
+ return lines.join("\n");
171
+ };
172
+
173
+ export default {
174
+ // palette
175
+ amber, warm, slate, green, red, bold, italic, value, label, ok, fail, em,
176
+ // layout
177
+ hr, hrThick, blank, header, kv, step, promptLine, errBlock, okBlock,
178
+ infoBlock, code, diff, logo, readyBlock,
179
+ // constants
180
+ INDENT, COL_W,
181
+ };
@@ -1,14 +1,10 @@
1
1
  import { homedir } from "os";
2
2
  import { join } from "path";
3
3
  import { readFileSync, writeFileSync, existsSync } from "fs";
4
- import readline from "readline";
5
- import c from "./colors.js";
4
+ import ui from "./colors.js";
6
5
 
7
6
  const CONFIG_PATH = join(homedir(), ".suron-config");
8
7
 
9
- /**
10
- * @returns {{ apiUrl?: string }}
11
- */
12
8
  export function readConfig() {
13
9
  if (!existsSync(CONFIG_PATH)) return {};
14
10
  try {
@@ -19,7 +15,7 @@ export function readConfig() {
19
15
  const key = line.slice(0, eq).trim();
20
16
  const val = line.slice(eq + 1).trim();
21
17
  if (!val) continue;
22
- if (key === "SURON_API_URL") config.apiUrl = val;
18
+ if (key === "SURON_TOKEN") config.token = val;
23
19
  }
24
20
  return config;
25
21
  } catch {
@@ -27,44 +23,28 @@ export function readConfig() {
27
23
  }
28
24
  }
29
25
 
30
- /**
31
- * @param {{ apiUrl?: string }} values
32
- */
33
26
  export function saveConfig(values) {
34
27
  const merged = { ...readConfig(), ...values };
35
28
  const lines = [];
36
- if (merged.apiUrl) lines.push(`SURON_API_URL=${merged.apiUrl}`);
29
+ if (merged.token) lines.push(`SURON_TOKEN=${merged.token}`);
37
30
  writeFileSync(CONFIG_PATH, lines.join("\n") + "\n", { mode: 0o600 });
38
31
  }
39
32
 
40
- /** @returns {string | null} */
41
- export function getApiUrl() {
42
- // Strip trailing slash from both sources so callers always get a clean URL.
43
- const fromEnv = process.env.SURON_API_URL?.replace(/\/$/, "");
44
- const fromConfig = readConfig().apiUrl?.replace(/\/$/, "");
45
- return fromEnv ?? fromConfig ?? null;
33
+ export function getToken() {
34
+ return readConfig().token ?? null;
46
35
  }
47
36
 
48
- /** @returns {string} */
49
- export function requireApiUrl() {
50
- const url = getApiUrl();
51
- if (!url) {
52
- console.error(c.red(" error:") + " not configured. Run: suron login");
37
+ export function requireToken() {
38
+ const token = getToken();
39
+ if (!token) {
40
+ console.error(ui.blank());
41
+ console.error(ui.errBlock("not logged in", "run: suron login"));
42
+ console.error(ui.blank());
53
43
  process.exit(1);
54
44
  }
55
- return url;
45
+ return token;
56
46
  }
57
47
 
58
- /**
59
- * @param {string} question
60
- * @returns {Promise<string>}
61
- */
62
- export function prompt(question) {
63
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
64
- return new Promise((resolve) => {
65
- rl.question(question, (answer) => {
66
- rl.close();
67
- resolve(answer.trim());
68
- });
69
- });
48
+ export function clearToken() {
49
+ writeFileSync(CONFIG_PATH, "", { mode: 0o600 });
70
50
  }
@@ -0,0 +1,7 @@
1
+ export function encryptEnv(plaintext) {
2
+ return plaintext;
3
+ }
4
+
5
+ export function decryptEnv(plaintext) {
6
+ return plaintext;
7
+ }