@suronai/cli 0.1.34 → 0.1.36

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@suronai/cli",
3
- "version": "0.1.34",
3
+ "version": "0.1.36",
4
4
  "description": "CLI for Suron — suron login, init, whoami, recover",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,88 @@
1
+ import { Command } from "commander";
2
+ import { existsSync, readFileSync } from "fs";
3
+ import { join } from "path";
4
+ import { encryptDotenv } from "../utils/dotenvx.js";
5
+ import ui from "../utils/colors.js";
6
+
7
+ export const encryptCommand = new Command("encrypt")
8
+ .description("Encrypt new plaintext values added to .env (safe to run any time after init)")
9
+ .action(async () => {
10
+ const cwd = process.cwd();
11
+
12
+ console.log(ui.blank());
13
+ console.log(ui.hr());
14
+ console.log(ui.blank());
15
+
16
+ if (!existsSync(join(cwd, ".suron.json"))) {
17
+ console.error(ui.errBlock(
18
+ ".suron.json not found",
19
+ "run suron init first"
20
+ ));
21
+ console.log(ui.blank());
22
+ process.exit(1);
23
+ }
24
+
25
+ if (!existsSync(join(cwd, ".env"))) {
26
+ console.error(ui.errBlock(".env not found"));
27
+ console.log(ui.blank());
28
+ process.exit(1);
29
+ }
30
+
31
+ // ── Scan for plaintext values ─────────────────────────────────────────────
32
+ const envLines = readFileSync(join(cwd, ".env"), "utf-8").split("\n");
33
+
34
+ const plaintextKeys = envLines
35
+ .filter(line => {
36
+ const t = line.trim();
37
+ if (!t || t.startsWith("#") || t.startsWith("#/")) return false;
38
+ const eq = t.indexOf("=");
39
+ if (eq === -1) return false;
40
+ const val = t.slice(eq + 1).trim().replace(/^["']|["']$/g, "");
41
+ return val.length > 0 && !val.startsWith("encrypted:");
42
+ })
43
+ .map(line => line.slice(0, line.indexOf("=")).trim());
44
+
45
+ if (plaintextKeys.length === 0) {
46
+ console.log(ui.step("skip", "all values already encrypted"));
47
+ console.log(ui.blank());
48
+ console.log(ui.hr());
49
+ console.log(ui.blank());
50
+ console.log(ui.infoBlock("to add secrets › edit .env then run " + ui.code("suron encrypt")));
51
+ console.log(ui.blank());
52
+ process.exit(0);
53
+ }
54
+
55
+ console.log(ui.kv("PENDING", String(plaintextKeys.length) + " value" + (plaintextKeys.length === 1 ? "" : "s")));
56
+ console.log(ui.blank());
57
+ for (const k of plaintextKeys) {
58
+ console.log(ui.INDENT + " " + ui.slate("· ") + ui.amber(k));
59
+ }
60
+ console.log(ui.blank());
61
+ console.log(ui.hr());
62
+ console.log(ui.blank());
63
+
64
+ // ── Encrypt ───────────────────────────────────────────────────────────────
65
+ process.stdout.write(ui.INDENT + ui.slate("encrypting ···"));
66
+
67
+ try {
68
+ encryptDotenv(cwd);
69
+ } catch (err) {
70
+ process.stdout.write("\r" + " ".repeat(40) + "\r");
71
+ console.error(ui.errBlock(err.message));
72
+ console.log(ui.blank());
73
+ process.exit(1);
74
+ }
75
+
76
+ process.stdout.write("\r" + " ".repeat(40) + "\r");
77
+
78
+ console.log(ui.step("done", "encrypted .env"));
79
+ console.log(ui.step("done", "cleaned .env + .env.keys"));
80
+ console.log(ui.blank());
81
+ console.log(ui.hr());
82
+ console.log(ui.blank());
83
+ console.log(ui.readyBlock([
84
+ { label: ".env", note: "encrypted · safe to commit" },
85
+ { label: ".env.keys", note: "keep safe · never commit" },
86
+ ]));
87
+ console.log(ui.blank());
88
+ });
@@ -1,12 +1,10 @@
1
1
  import { Command } from "commander";
2
2
  import { existsSync, writeFileSync, readFileSync } from "fs";
3
- import { join, basename } from "path";
3
+ import { join, basename, relative } from "path";
4
4
  import { execSync } from "child_process";
5
5
  import { requireApiUrl, prompt } from "../utils/config.js";
6
6
  import { encryptDotenv, readPrivateKey } from "../utils/dotenvx.js";
7
- import c from "../utils/colors.js";
8
-
9
- const HR = " " + c.dim("─".repeat(55));
7
+ import ui from "../utils/colors.js";
10
8
 
11
9
  export const initCommand = new Command("init")
12
10
  .description("Encrypt .env and register this app with Suron")
@@ -15,67 +13,89 @@ export const initCommand = new Command("init")
15
13
  const cwd = process.cwd();
16
14
  const apiUrl = requireApiUrl();
17
15
 
18
- console.log();
16
+ console.log(ui.logo());
17
+ console.log(ui.blank());
18
+ console.log(ui.hr());
19
+ console.log(ui.blank());
19
20
 
20
21
  if (existsSync(join(cwd, ".suron.json"))) {
21
- console.error(" " + c.red("✗") + " already initialised — .suron.json exists");
22
- console.error(" To restore a lost config: " + c.bold("suron recover") + "\n");
22
+ console.error(ui.errBlock(
23
+ "already initialised",
24
+ "to restore a lost config › suron recover"
25
+ ));
26
+ console.log(ui.blank());
23
27
  process.exit(1);
24
28
  }
25
29
 
26
30
  if (!existsSync(join(cwd, ".env"))) {
27
- console.error(" " + c.red("✗") + " .env not found");
28
- console.error(" Create a .env file with your secrets, then run: suron init\n");
31
+ console.error(ui.errBlock(
32
+ ".env not found",
33
+ "create a .env file with your secrets, then run: suron init"
34
+ ));
35
+ console.log(ui.blank());
29
36
  process.exit(1);
30
37
  }
31
38
 
32
39
  // ── App name ──────────────────────────────────────────────────────────────
33
- const suggested = sanitiseName(basename(cwd) || "my-project");
40
+ const suggested = sanitiseName(basename(cwd) || "myapp");
34
41
  let appName;
35
42
 
36
43
  if (opts.name) {
37
44
  appName = sanitiseName(opts.name);
38
45
  } else {
39
- const raw = await prompt(" App name " + c.dim("· ") + c.dim("[" + suggested + "]"));
46
+ process.stdout.write(ui.promptLine("app name [" + suggested + "]"));
47
+ const raw = await prompt("");
40
48
  appName = sanitiseName(raw || suggested);
41
49
  }
42
50
 
43
- console.log(" App name " + c.dim("·") + " " + c.cyan(appName));
44
- console.log(HR);
51
+ if (!appName) {
52
+ console.log(ui.blank());
53
+ console.error(ui.errBlock(
54
+ "app name required",
55
+ "example › suron init --name MyApp"
56
+ ));
57
+ console.log(ui.blank());
58
+ process.exit(1);
59
+ }
60
+
61
+ console.log(ui.blank());
62
+ console.log(ui.kv("APP", appName));
63
+ console.log(ui.kv("DIR", ui.slate(cwd)));
64
+ console.log(ui.blank());
65
+ console.log(ui.hr());
66
+ console.log(ui.blank());
45
67
 
46
- // ── Steps tracker ─────────────────────────────────────────────────────────
68
+ // ── Steps ─────────────────────────────────────────────────────────────────
47
69
  const steps = {
48
- encrypt: { label: "Encrypt", detail: ".env", status: "pending" },
49
- keys: { label: "Key", detail: ".env.keys", status: "pending" },
50
- gitignore:{ label: "Gitignore",detail: ".gitignore", status: "pending" },
51
- register: { label: "Register", detail: appName, status: "pending" },
52
- sdk: { label: "SDK", detail: "@suronai/sdk", status: "pending" },
70
+ encrypt: { label: "encrypt .env", status: "pending" },
71
+ keys: { label: "key .env.keys", status: "pending" },
72
+ gitignore: { label: "gitignore .gitignore", status: "pending" },
73
+ register: { label: "register " + appName, status: "pending" },
74
+ sdk: { label: "sdk @suronai/sdk", status: "pending" },
53
75
  };
54
76
 
55
- function printSteps() {
77
+ const printSteps = () => {
56
78
  for (const s of Object.values(steps)) {
57
- const dot = s.status === "done" ? c.green("●")
58
- : s.status === "skip" ? c.dim("○")
59
- : s.status === "fail" ? c.red("●")
60
- : c.dim("○");
61
- const detail = s.status === "fail" ? c.red(s.detail) : c.dim(s.detail);
62
- const label = s.label.padEnd(11);
63
- const dots = c.dim(".".repeat(Math.max(2, 44 - label.length - s.detail.length)));
64
- const note = s.note ? " " + c.dim(s.note) : "";
65
- console.log(" " + dot + " " + label + detail + dots + (s.status === "done" ? c.green(" done") : s.status === "skip" ? c.dim(" skip") : s.status === "fail" ? c.red(" fail") : "") + note);
79
+ console.log(ui.step(s.status, s.label, s.note));
66
80
  }
67
- }
81
+ };
68
82
 
69
83
  // ── Encrypt ───────────────────────────────────────────────────────────────
84
+ steps.encrypt.status = "run";
85
+ steps.keys.status = "run";
70
86
  try {
71
87
  encryptDotenv(cwd);
72
- steps.encrypt.status = "done";
73
- steps.keys.status = "done";
88
+ steps.encrypt.status = "done";
89
+ steps.keys.status = "done";
74
90
  } catch (err) {
75
91
  steps.encrypt.status = "fail";
92
+ steps.keys.status = "fail";
76
93
  printSteps();
77
- console.log(HR);
78
- console.error("\n " + c.red("✗") + " " + err.message + "\n");
94
+ console.log(ui.blank());
95
+ console.log(ui.hr());
96
+ console.log(ui.blank());
97
+ console.error(ui.errBlock(err.message));
98
+ console.log(ui.blank());
79
99
  process.exit(1);
80
100
  }
81
101
 
@@ -85,17 +105,22 @@ export const initCommand = new Command("init")
85
105
  } catch (err) {
86
106
  steps.keys.status = "fail";
87
107
  printSteps();
88
- console.log(HR);
89
- console.error("\n " + c.red("✗") + " " + err.message + "\n");
108
+ console.log(ui.blank());
109
+ console.log(ui.hr());
110
+ console.log(ui.blank());
111
+ console.error(ui.errBlock(err.message));
112
+ console.log(ui.blank());
90
113
  process.exit(1);
91
114
  }
92
115
 
93
116
  // ── .gitignore ────────────────────────────────────────────────────────────
117
+ steps.gitignore.status = "run";
94
118
  const gitignoreResult = ensureGitignore(cwd);
95
119
  steps.gitignore.status = "done";
96
120
  steps.gitignore.note = gitignoreResult;
97
121
 
98
122
  // ── Register ──────────────────────────────────────────────────────────────
123
+ steps.register.status = "run";
99
124
  let res;
100
125
  try {
101
126
  res = await fetch(`${apiUrl}/cli/register-app`, {
@@ -106,8 +131,11 @@ export const initCommand = new Command("init")
106
131
  } catch (err) {
107
132
  steps.register.status = "fail";
108
133
  printSteps();
109
- console.log(HR);
110
- console.error("\n " + c.red("✗") + ` Could not reach Suron API: ${err.message}\n`);
134
+ console.log(ui.blank());
135
+ console.log(ui.hr());
136
+ console.log(ui.blank());
137
+ console.error(ui.errBlock(`cannot reach API`, err.message));
138
+ console.log(ui.blank());
111
139
  process.exit(1);
112
140
  }
113
141
 
@@ -116,13 +144,19 @@ export const initCommand = new Command("init")
116
144
  try { body = await res.json(); } catch { /* ignore */ }
117
145
  steps.register.status = "fail";
118
146
  printSteps();
119
- console.log(HR);
147
+ console.log(ui.blank());
148
+ console.log(ui.hr());
149
+ console.log(ui.blank());
120
150
  if (res.status === 409) {
121
- console.error("\n " + c.red("✗") + ` An app named "${c.cyan(appName)}" is already registered.`);
122
- console.error(" If you lost .suron.json, run: " + c.bold("suron recover") + "\n");
151
+ const canonical = body?.existing_name ?? appName;
152
+ console.error(ui.errBlock(
153
+ `"${canonical}" already registered`,
154
+ `to restore a lost config › suron recover --name ${canonical}`
155
+ ));
123
156
  } else {
124
- console.error("\n " + c.red("✗") + ` ${body?.error ?? `register-app failed (${res.status})`}\n`);
157
+ console.error(ui.errBlock(body?.error ?? `register failed (${res.status})`));
125
158
  }
159
+ console.log(ui.blank());
126
160
  process.exit(1);
127
161
  }
128
162
 
@@ -139,6 +173,7 @@ export const initCommand = new Command("init")
139
173
  // ── Install SDK ───────────────────────────────────────────────────────────
140
174
  const pkgJsonPath = join(cwd, "package.json");
141
175
  let isEsm = false;
176
+ steps.sdk.status = "run";
142
177
 
143
178
  if (existsSync(pkgJsonPath)) {
144
179
  let alreadyInstalled = false;
@@ -167,51 +202,41 @@ export const initCommand = new Command("init")
167
202
  }
168
203
 
169
204
  printSteps();
170
- console.log(HR);
205
+ console.log(ui.blank());
206
+ console.log(ui.hr());
207
+ console.log(ui.blank());
171
208
 
172
209
  // ── Patch entry point ─────────────────────────────────────────────────────
173
210
  await patchEntryPoint(cwd, isEsm);
174
211
 
175
212
  // ── Done ──────────────────────────────────────────────────────────────────
176
- console.log();
177
- console.log(" " + c.dim("◇") + " .env encrypted " + c.dim("safe to commit"));
178
- console.log(" " + c.dim("◇") + " .env.keys " + c.dim("gitignored, keep it safe"));
179
- console.log(" " + c.dim("◇") + " .suron.json " + c.dim("safe to commit"));
180
- console.log(" " + c.dim("◆") + " " + c.bold("ready"));
181
- console.log();
213
+ console.log(ui.readyBlock([
214
+ { label: ".env", note: "encrypted · safe to commit" },
215
+ { label: ".env.keys", note: "gitignored · keep safe" },
216
+ { label: ".suron.json", note: "safe to commit" },
217
+ ]));
218
+ console.log(ui.blank());
182
219
  });
183
220
 
184
- /**
185
- * Ensures standard entries are in .gitignore.
186
- * Creates the file if it doesn't exist.
187
- * @param {string} cwd
188
- * @returns {string} short note for the steps display
189
- */
190
- const GITIGNORE_ENTRIES = [
191
- "node_modules/",
192
- "package-lock.json",
193
- ".env.keys",
194
- ];
221
+ // ── Helpers ──────────────────────────────────────────────────────────────────
222
+
223
+ const GITIGNORE_ENTRIES = ["node_modules/", "package-lock.json", ".env.keys"];
195
224
 
196
225
  function ensureGitignore(cwd) {
197
226
  const gitignorePath = join(cwd, ".gitignore");
198
-
199
227
  if (!existsSync(gitignorePath)) {
200
228
  writeFileSync(gitignorePath, GITIGNORE_ENTRIES.join("\n") + "\n", "utf-8");
201
229
  return "created";
202
230
  }
203
-
204
231
  const content = readFileSync(gitignorePath, "utf-8");
205
232
  const existing = new Set(content.split("\n").map(l => l.trim()));
206
233
  const missing = GITIGNORE_ENTRIES.filter(e => !existing.has(e));
207
- if (missing.length === 0) return "already set";
208
-
209
- const separator = content.endsWith("\n") ? "" : "\n";
210
- writeFileSync(gitignorePath, content + separator + missing.join("\n") + "\n", "utf-8");
234
+ if (missing.length === 0) return "ok";
235
+ const sep = content.endsWith("\n") ? "" : "\n";
236
+ writeFileSync(gitignorePath, content + sep + missing.join("\n") + "\n", "utf-8");
211
237
  return "updated";
212
238
  }
213
239
 
214
- /** @param {string} cwd @returns {string} */
215
240
  function detectPackageManager(cwd) {
216
241
  if (existsSync(join(cwd, "pnpm-lock.yaml"))) return "pnpm";
217
242
  if (existsSync(join(cwd, "yarn.lock"))) return "yarn";
@@ -219,17 +244,17 @@ function detectPackageManager(cwd) {
219
244
  return "npm";
220
245
  }
221
246
 
222
- /** @param {string} pm @returns {string} */
223
247
  function pmAddCmd(pm) {
224
248
  return pm === "npm" ? "install" : "add";
225
249
  }
226
250
 
227
251
  /**
252
+ * Strips everything except letters and digits while preserving case.
228
253
  * @param {string} name
229
254
  * @returns {string}
230
255
  */
231
256
  function sanitiseName(name) {
232
- return name.toLowerCase().replace(/[^a-z0-9]/g, "");
257
+ return name.replace(/[^a-zA-Z0-9]/g, "");
233
258
  }
234
259
 
235
260
  /**
@@ -248,9 +273,6 @@ async function patchEntryPoint(cwd, isEsm) {
248
273
  }
249
274
 
250
275
  if (!entryPath) {
251
- console.log(" " + c.dim("▎"));
252
- console.log(" " + c.dim("▎") + " Add to your app entry point:");
253
- console.log(" " + c.dim("▎"));
254
276
  printSnippet(isEsm);
255
277
  return;
256
278
  }
@@ -259,8 +281,6 @@ async function patchEntryPoint(cwd, isEsm) {
259
281
  try { src = readFileSync(entryPath, "utf-8"); } catch { return; }
260
282
 
261
283
  const lines = src.split("\n");
262
-
263
- // Pass 1 — lines containing "dotenv"
264
284
  const toReplace = [];
265
285
  const seenIndices = new Set();
266
286
 
@@ -284,7 +304,6 @@ async function patchEntryPoint(cwd, isEsm) {
284
304
  }
285
305
  }
286
306
 
287
- // Pass 2 — bare config() call (ESM only — import and call are on separate lines)
288
307
  if (isEsm) {
289
308
  for (let i = 0; i < lines.length; i++) {
290
309
  if (seenIndices.has(i)) continue;
@@ -302,56 +321,49 @@ async function patchEntryPoint(cwd, isEsm) {
302
321
  }
303
322
 
304
323
  if (toReplace.length === 0) {
305
- console.log(" " + c.dim("▎"));
306
- console.log(" " + c.dim("▎") + " Add to your app entry point:");
307
- console.log(" " + c.dim("▎"));
308
324
  printSnippet(isEsm);
309
325
  return;
310
326
  }
311
327
 
312
- const relEntry = entryPath.replace(cwd + "\\", "").replace(cwd + "/", "");
328
+ const relEntry = relative(cwd, entryPath);
313
329
 
314
330
  // ── Diff preview ───────────────────────────────────────────────────────────
315
- console.log(" " + c.dim("▎"));
316
- console.log(" " + c.dim("▎ ") + c.dim(relEntry));
317
- console.log(" " + c.dim("▎"));
331
+ console.log(ui.kv("ENTRY", relEntry, 8));
332
+ console.log(ui.blank());
318
333
 
319
334
  for (const { content, replacement } of toReplace) {
320
- console.log(" " + c.dim("") + c.red("⁻") + " " + c.dim(content.trim()));
335
+ console.log(ui.diff("remove", content.trim()));
321
336
  if (replacement !== null) {
322
337
  for (const l of replacement.split("\n")) {
323
- console.log(" " + c.dim("") + c.green("⁺") + " " + c.cyan(l.trim()));
338
+ console.log(ui.diff("add", l.trim()));
324
339
  }
325
340
  } else {
326
- console.log(" " + c.dim("") + c.yellow("?") + " " + c.dim("(no automatic replacement — update manually)"));
341
+ console.log(ui.diff("unknown", ""));
327
342
  }
328
- console.log(" " + c.dim("▎"));
343
+ console.log(ui.blank());
329
344
  }
330
345
 
331
- const answer = await prompt(" " + c.dim("▎") + " apply? " + c.dim("[Y/n]"));
346
+ process.stdout.write(ui.promptLine("apply patch? [Y/n]"));
347
+ const answer = await prompt("");
332
348
  const confirmed = answer === "" || /^y(es)?$/i.test(answer);
333
- console.log(HR);
349
+ console.log(ui.blank());
350
+ console.log(ui.hr());
351
+ console.log(ui.blank());
334
352
 
335
353
  if (!confirmed) {
336
- console.log();
337
- console.log(" " + c.dim("skipped — add manually:"));
338
- console.log();
339
354
  printSnippet(isEsm);
340
355
  return;
341
356
  }
342
357
 
343
- // Apply
344
358
  const indexMap = new Map(toReplace.map(r => [r.index, r]));
345
359
  const outLines = lines.map((line, i) => {
346
360
  const r = indexMap.get(i);
347
361
  return (r && r.replacement !== null) ? r.replacement : line;
348
362
  });
349
363
 
350
- // Move await config() to right after last import block (ESM)
351
364
  if (isEsm) {
352
- const callLineIndex = outLines.findIndex(l => /^\s*await config\s*\(/.test(l));
365
+ const callLineIndex = outLines.findIndex(l => /^\s*await config\s*\(/.test(l));
353
366
  const lastImportIndex = outLines.reduce((last, l, i) => l.trimStart().startsWith("import ") ? i : last, -1);
354
-
355
367
  if (callLineIndex !== -1 && callLineIndex > lastImportIndex + 2) {
356
368
  const callLine = outLines[callLineIndex];
357
369
  outLines.splice(callLineIndex, 1);
@@ -364,20 +376,22 @@ async function patchEntryPoint(cwd, isEsm) {
364
376
  try {
365
377
  writeFileSync(entryPath, outLines.join("\n"), "utf-8");
366
378
  } catch (err) {
367
- console.error(" " + c.red("✗") + " Could not write " + relEntry + ": " + err.message);
379
+ console.error(ui.errBlock("could not write " + relEntry, err.message));
368
380
  printSnippet(isEsm);
369
381
  }
370
382
  }
371
383
 
372
- /** @param {boolean} isEsm */
373
384
  function printSnippet(isEsm) {
385
+ console.log(ui.infoBlock("add to your entry point:"));
386
+ console.log(ui.blank());
374
387
  if (isEsm) {
375
- console.log(" " + c.dim("") + c.cyan("import") + " { config } from " + c.green("'@suronai/sdk'"));
376
- console.log(" " + c.dim("") + c.cyan("await") + " config()");
388
+ console.log(ui.INDENT + " " + ui.amber('import { config } from "@suronai/sdk"'));
389
+ console.log(ui.INDENT + " " + ui.amber("await config()"));
377
390
  } else {
378
- console.log(" " + c.dim("") + "const { config } = " + c.cyan("require") + "(" + c.green("'@suronai/sdk'") + ")");
379
- console.log(" " + c.dim("") + c.cyan("await") + " config()");
391
+ console.log(ui.INDENT + " " + ui.amber('const { config } = require("@suronai/sdk")'));
392
+ console.log(ui.INDENT + " " + ui.amber("await config()"));
380
393
  }
381
- console.log(" " + c.dim("▎"));
382
- console.log(HR);
394
+ console.log(ui.blank());
395
+ console.log(ui.hr());
396
+ console.log(ui.blank());
383
397
  }
@@ -1,56 +1,83 @@
1
1
  import { Command } from "commander";
2
2
  import { getApiUrl, saveConfig, prompt } from "../utils/config.js";
3
- import c from "../utils/colors.js";
3
+ import ui from "../utils/colors.js";
4
4
 
5
5
  export const loginCommand = new Command("login")
6
6
  .description("Configure the Suron API URL")
7
7
  .action(async () => {
8
- console.log("\n" + c.bold(" Suron") + " — configure your backend\n");
8
+ console.log(ui.logo());
9
+ console.log(ui.blank());
10
+ console.log(ui.hr());
11
+ console.log(ui.blank());
9
12
 
10
13
  let apiUrl = getApiUrl();
11
14
 
12
15
  if (apiUrl) {
13
- console.log(" Current URL " + c.cyan(apiUrl));
14
- const change = await prompt(" Change it? (y/N) › ");
16
+ console.log(ui.kv("ENDPOINT", apiUrl));
17
+ console.log(ui.blank());
18
+ process.stdout.write(ui.promptLine("replace? [y/N]"));
19
+ const change = await prompt("");
15
20
  if (change.toLowerCase() !== "y") {
16
- console.log("\n " + c.green("✓") + " No changes made.\n");
21
+ console.log(ui.blank());
22
+ console.log(ui.okBlock("no changes"));
23
+ console.log(ui.blank());
17
24
  return;
18
25
  }
26
+ console.log(ui.blank());
19
27
  }
20
28
 
21
- const input = await prompt(" Convex deployment URL");
29
+ process.stdout.write(ui.promptLine("convex deployment URL"));
30
+ const input = await prompt("");
31
+
22
32
  if (!input) {
23
- console.error("\n " + c.red("✗") + " URL is required.\n");
33
+ console.log(ui.blank());
34
+ console.error(ui.errBlock("URL required"));
35
+ console.log(ui.blank());
24
36
  process.exit(1);
25
37
  }
26
- apiUrl = input.replace(/\/$/, "");
27
38
 
28
- process.stdout.write("\n Verifying connection...");
39
+ apiUrl = input.trim().replace(/\/$/, "");
40
+ console.log(ui.blank());
41
+ console.log(ui.hr());
42
+ console.log(ui.blank());
43
+
44
+ // Verify
45
+ process.stdout.write(ui.INDENT + ui.slate("verifying ···"));
29
46
 
47
+ let verified = false;
30
48
  try {
31
49
  const res = await fetch(`${apiUrl}/cli/verify`, {
32
50
  method: "POST",
33
51
  headers: { "Content-Type": "application/json" },
34
52
  body: JSON.stringify({}),
35
53
  });
54
+ verified = res.ok;
36
55
  if (!res.ok) {
37
56
  const text = await res.text().catch(() => "");
38
- process.stdout.write("\r" + " ".repeat(30) + "\r");
39
- console.error(" " + c.red("✗") + ` Backend returned ${res.status}: ${text}\n`);
57
+ process.stdout.write("\r" + " ".repeat(40) + "\r");
58
+ console.error(ui.errBlock(`backend returned ${res.status}`, text || undefined));
59
+ console.log(ui.blank());
40
60
  process.exit(1);
41
61
  }
42
62
  } catch {
43
- process.stdout.write("\r" + " ".repeat(30) + "\r");
44
- console.error(" " + c.red("✗") + ` Could not reach ${c.cyan(apiUrl)}`);
45
- console.error(" Is the Convex deployment running?\n");
63
+ process.stdout.write("\r" + " ".repeat(40) + "\r");
64
+ console.error(ui.errBlock(
65
+ `cannot reach ${apiUrl}`,
66
+ "is the convex deployment running?"
67
+ ));
68
+ console.log(ui.blank());
46
69
  process.exit(1);
47
70
  }
48
71
 
49
- process.stdout.write("\r" + " ".repeat(30) + "\r");
72
+ process.stdout.write("\r" + " ".repeat(40) + "\r");
50
73
 
51
74
  saveConfig({ apiUrl });
52
75
 
53
- console.log(" " + c.green("") + " Connected");
54
- console.log(" " + c.green("") + " Saved to ~/.suron-config");
55
- console.log("\n Next: cd your-project && " + c.bold("suron init") + "\n");
76
+ console.log(ui.step("done", "connection verified"));
77
+ console.log(ui.step("done", "saved ~/.suron-config"));
78
+ console.log(ui.blank());
79
+ console.log(ui.hr());
80
+ console.log(ui.blank());
81
+ console.log(ui.infoBlock("next › cd your-project && " + ui.code("suron init")));
82
+ console.log(ui.blank());
56
83
  });
@@ -2,7 +2,7 @@ import { Command } from "commander";
2
2
  import { existsSync, writeFileSync } from "fs";
3
3
  import { join, basename } from "path";
4
4
  import { requireApiUrl, prompt } from "../utils/config.js";
5
- import c from "../utils/colors.js";
5
+ import ui from "../utils/colors.js";
6
6
 
7
7
  export const recoverCommand = new Command("recover")
8
8
  .description("Restore a lost .suron.json by looking up your app name")
@@ -11,35 +11,52 @@ export const recoverCommand = new Command("recover")
11
11
  const cwd = process.cwd();
12
12
  const apiUrl = requireApiUrl();
13
13
 
14
- console.log("\n" + c.bold(" suron recover") + " — " + c.dim(cwd) + "\n");
14
+ console.log(ui.blank());
15
+ console.log(ui.hr());
16
+ console.log(ui.blank());
17
+ console.log(ui.kv("DIR", ui.slate(cwd)));
18
+ console.log(ui.blank());
15
19
 
16
20
  if (existsSync(join(cwd, ".suron.json"))) {
17
- console.log(" " + c.yellow("⚠") + " .suron.json already exists here.");
18
- const answer = await prompt(" Overwrite it? [y/N] › ");
21
+ console.log(ui.infoBlock(".suron.json already exists here"));
22
+ console.log(ui.blank());
23
+ process.stdout.write(ui.promptLine("overwrite? [y/N]"));
24
+ const answer = await prompt("");
19
25
  if (!/^y(es)?$/i.test(answer)) {
20
- console.log(" " + c.dim("Cancelled.\n"));
26
+ console.log(ui.blank());
27
+ console.log(ui.infoBlock("cancelled"));
28
+ console.log(ui.blank());
21
29
  process.exit(0);
22
30
  }
31
+ console.log(ui.blank());
23
32
  }
24
33
 
25
34
  // ── App name ──────────────────────────────────────────────────────────────
26
35
  let appName;
27
36
  if (opts.name) {
28
- appName = opts.name.toLowerCase().replace(/[^a-z0-9]/g, "");
29
- console.log(" App name " + c.cyan(appName));
37
+ appName = opts.name.replace(/[^a-zA-Z0-9]/g, "");
30
38
  } else {
31
- const suggested = basename(cwd) || "my-project";
32
- const raw = await prompt(` App name [${c.dim(suggested)}] `);
33
- appName = (raw || suggested).toLowerCase().replace(/[^a-z0-9]/g, "");
39
+ const suggested = sanitiseName(basename(cwd) || "myapp");
40
+ process.stdout.write(ui.promptLine("app name [" + suggested + "]"));
41
+ const raw = await prompt("");
42
+ appName = sanitiseName(raw || suggested);
34
43
  }
35
44
 
36
45
  if (!appName) {
37
- console.error(" " + c.red("✗") + " App name is required.\n");
46
+ console.log(ui.blank());
47
+ console.error(ui.errBlock("app name required"));
48
+ console.log(ui.blank());
38
49
  process.exit(1);
39
50
  }
40
51
 
52
+ console.log(ui.blank());
53
+ console.log(ui.kv("LOOKUP", appName));
54
+ console.log(ui.blank());
55
+ console.log(ui.hr());
56
+ console.log(ui.blank());
57
+
41
58
  // ── Look up app ───────────────────────────────────────────────────────────
42
- console.log("\n " + c.dim("Looking up app..."));
59
+ process.stdout.write(ui.INDENT + ui.slate("querying ···"));
43
60
 
44
61
  let res;
45
62
  try {
@@ -49,19 +66,26 @@ export const recoverCommand = new Command("recover")
49
66
  body: JSON.stringify({ name: appName }),
50
67
  });
51
68
  } catch (err) {
52
- console.error("\n " + c.red("✗") + ` Could not reach Suron API: ${err.message}\n`);
69
+ process.stdout.write("\r" + " ".repeat(40) + "\r");
70
+ console.error(ui.errBlock(`cannot reach API`, err.message));
71
+ console.log(ui.blank());
53
72
  process.exit(1);
54
73
  }
55
74
 
75
+ process.stdout.write("\r" + " ".repeat(40) + "\r");
76
+
56
77
  if (!res.ok) {
57
78
  let body = {};
58
79
  try { body = await res.json(); } catch { /* ignore */ }
59
80
  if (res.status === 404) {
60
- console.error("\n " + c.red("✗") + ` No app named "${c.cyan(appName)}" found.`);
61
- console.error(" Check the name carefully — names are lowercase and alphanumeric only.\n");
81
+ console.error(ui.errBlock(
82
+ `"${appName}" not found`,
83
+ "lookup is case-insensitive — check spelling"
84
+ ));
62
85
  } else {
63
- console.error("\n " + c.red("✗") + ` ${body?.error ?? `recover-app failed (${res.status})`}\n`);
86
+ console.error(ui.errBlock(body?.error ?? `recover failed (${res.status})`));
64
87
  }
88
+ console.log(ui.blank());
65
89
  process.exit(1);
66
90
  }
67
91
 
@@ -74,9 +98,15 @@ export const recoverCommand = new Command("recover")
74
98
  "utf-8"
75
99
  );
76
100
 
77
- console.log();
78
- console.log(" " + c.green("✓") + " .suron.json restored");
79
- console.log(" " + c.dim(" app: ") + c.cyan(name));
80
- console.log(" " + c.dim(" id: ") + c.cyan(app_id));
81
- console.log();
101
+ console.log(ui.step("done", ".suron.json written"));
102
+ console.log(ui.blank());
103
+ console.log(ui.kv("APP", name));
104
+ console.log(ui.kv("ID", app_id));
105
+ console.log(ui.blank());
106
+ console.log(ui.hr());
107
+ console.log(ui.blank());
82
108
  });
109
+
110
+ function sanitiseName(name) {
111
+ return name.replace(/[^a-zA-Z0-9]/g, "");
112
+ }
@@ -1,13 +1,17 @@
1
1
  import { Command } from "commander";
2
2
  import { requireApiUrl } from "../utils/config.js";
3
- import c from "../utils/colors.js";
3
+ import ui from "../utils/colors.js";
4
4
 
5
5
  export const whoamiCommand = new Command("whoami")
6
6
  .description("Show configured Suron API URL")
7
7
  .action(() => {
8
8
  const apiUrl = requireApiUrl();
9
- console.log();
10
- console.log(" " + c.bold("API URL") + " " + c.cyan(apiUrl));
11
- console.log(" " + c.bold("Config file") + " ~/.suron-config");
12
- console.log();
9
+ console.log(ui.blank());
10
+ console.log(ui.hr());
11
+ console.log(ui.blank());
12
+ console.log(ui.kv("ENDPOINT", apiUrl));
13
+ console.log(ui.kv("CONFIG", "~/.suron-config"));
14
+ console.log(ui.blank());
15
+ console.log(ui.hr());
16
+ console.log(ui.blank());
13
17
  });
package/src/index.js CHANGED
@@ -4,6 +4,7 @@ import { Command } from "commander";
4
4
  import { createRequire } from "module";
5
5
  import { loginCommand } from "./commands/login.js";
6
6
  import { initCommand } from "./commands/init.js";
7
+ import { encryptCommand } from "./commands/encrypt.js";
7
8
  import { whoamiCommand } from "./commands/whoami.js";
8
9
  import { recoverCommand } from "./commands/recover.js";
9
10
 
@@ -19,6 +20,7 @@ program
19
20
 
20
21
  program.addCommand(loginCommand);
21
22
  program.addCommand(initCommand);
23
+ program.addCommand(encryptCommand);
22
24
  program.addCommand(whoamiCommand);
23
25
  program.addCommand(recoverCommand);
24
26
 
@@ -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
+ };
@@ -2,7 +2,7 @@ import { homedir } from "os";
2
2
  import { join } from "path";
3
3
  import { readFileSync, writeFileSync, existsSync } from "fs";
4
4
  import readline from "readline";
5
- import c from "./colors.js";
5
+ import ui from "./colors.js";
6
6
 
7
7
  const CONFIG_PATH = join(homedir(), ".suron-config");
8
8
 
@@ -49,7 +49,9 @@ export function getApiUrl() {
49
49
  export function requireApiUrl() {
50
50
  const url = getApiUrl();
51
51
  if (!url) {
52
- console.error(c.red(" error:") + " not configured. Run: suron login");
52
+ console.error(ui.blank());
53
+ console.error(ui.errBlock("not configured", "run: suron login"));
54
+ console.error(ui.blank());
53
55
  process.exit(1);
54
56
  }
55
57
  return url;
@@ -1,25 +1,73 @@
1
1
  import { execSync } from "child_process";
2
- import { existsSync, readFileSync } from "fs";
2
+ import { existsSync, readFileSync, writeFileSync } from "fs";
3
3
  import { join } from "path";
4
4
 
5
5
  /**
6
- * Encrypts .env in-place using dotenvx.
7
- * On success: .env is encrypted, .env.keys contains the private key.
8
- * If dotenvx exits 0 but .env.keys doesn't exist, the file was already
9
- * encrypted — readPrivateKey() will throw a clear error explaining that.
6
+ * Strips the dotenvx banner comment block from a string of file content.
7
+ *
8
+ * dotenvx injects a block like this into both .env and .env.keys on every
9
+ * encrypt call:
10
+ *
11
+ * #/------------------!DOTENV_PRIVATE_KEYS!---...
12
+ * #/ private decryption keys. DO NOT commit ...
13
+ * #/ [how it works](https://dotenvx.com/encryption)
14
+ * #/ backup with: `dotenvx ops backup`
15
+ * #/----------------------------------------------------------/
16
+ *
17
+ * These lines all start with "#/" which regular user comments never do
18
+ * (user comments use a plain "#" prefix), so this is a safe, targeted strip.
19
+ *
20
+ * @param {string} content Raw file content
21
+ * @returns {string} Content with all #/ banner lines removed
22
+ */
23
+ export function stripDotenvxComments(content) {
24
+ return content
25
+ .split("\n")
26
+ .filter(line => !line.startsWith("#/"))
27
+ .join("\n")
28
+ .replace(/\n{3,}/g, "\n\n") // collapse excess blank lines left behind
29
+ .trimStart();
30
+ }
31
+
32
+ /**
33
+ * Strips dotenvx banner comments from .env and .env.keys in-place.
34
+ * Called automatically by encryptDotenv() after every encrypt run,
35
+ * but exported so callers can run it standalone if needed.
36
+ * @param {string} cwd
37
+ */
38
+ export function cleanDotenvComments(cwd) {
39
+ for (const filename of [".env", ".env.keys"]) {
40
+ const filePath = join(cwd, filename);
41
+ if (!existsSync(filePath)) continue;
42
+ const raw = readFileSync(filePath, "utf-8");
43
+ const cleaned = stripDotenvxComments(raw);
44
+ if (cleaned !== raw) writeFileSync(filePath, cleaned, "utf-8");
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Encrypts .env in-place using dotenvx, then strips the injected banner
50
+ * comments from both .env and .env.keys automatically.
51
+ *
52
+ * Idempotent — dotenvx only encrypts new plaintext values and leaves
53
+ * already-encrypted values untouched, reusing the existing keypair.
54
+ * Safe to call on first init AND any time new values are added to .env.
55
+ *
10
56
  * @param {string} cwd
11
57
  */
12
58
  export function encryptDotenv(cwd) {
13
59
  if (!existsSync(join(cwd, ".env"))) {
14
60
  throw new Error(`.env not found in ${cwd}`);
15
61
  }
62
+
16
63
  // Strip all dotenvx keypair vars from the child environment.
17
- // If dotenvx sees DOTENV_PRIVATE_KEY *or* DOTENV_PUBLIC_KEY already set
18
- // it reuses that keypair and skips writing .env.keys entirely, which
19
- // breaks readPrivateKey() immediately after.
64
+ // If dotenvx sees DOTENV_PRIVATE_KEY or DOTENV_PUBLIC_KEY already set
65
+ // it may skip writing .env.keys, breaking readPrivateKey() on first run.
20
66
  const env = { ...process.env };
21
67
  for (const k of Object.keys(env)) {
22
- if (k.startsWith("DOTENV_PRIVATE_KEY") || k.startsWith("DOTENV_PUBLIC_KEY")) delete env[k];
68
+ if (k.startsWith("DOTENV_PRIVATE_KEY") || k.startsWith("DOTENV_PUBLIC_KEY")) {
69
+ delete env[k];
70
+ }
23
71
  }
24
72
 
25
73
  try {
@@ -35,6 +83,9 @@ export function encryptDotenv(cwd) {
35
83
  if (stdout) process.stdout.write(stdout);
36
84
  throw new Error(`dotenvx encrypt failed: ${stderr || err}`);
37
85
  }
86
+
87
+ // Always strip the banner dotenvx injects on every encrypt call.
88
+ cleanDotenvComments(cwd);
38
89
  }
39
90
 
40
91
  /**
@@ -57,5 +108,3 @@ export function readPrivateKey(cwd) {
57
108
  }
58
109
  return match[1].trim();
59
110
  }
60
-
61
-