@suronai/cli 0.1.35 → 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.35",
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
+ });
@@ -4,9 +4,7 @@ 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,74 +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
- // Preserve the folder's original casing as the default suggestion.
34
40
  const suggested = sanitiseName(basename(cwd) || "myapp");
35
41
  let appName;
36
42
 
37
43
  if (opts.name) {
38
44
  appName = sanitiseName(opts.name);
39
45
  } else {
40
- 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("");
41
48
  appName = sanitiseName(raw || suggested);
42
49
  }
43
50
 
44
51
  if (!appName) {
45
- console.error(" " + c.red("✗") + " App name is required and must contain at least one letter or digit.");
46
- console.error(" Example: suron init --name MyApp\n");
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());
47
58
  process.exit(1);
48
59
  }
49
60
 
50
- console.log(" App name " + c.dim("·") + " " + c.cyan(appName));
51
- console.log(HR);
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());
52
67
 
53
- // ── Steps tracker ─────────────────────────────────────────────────────────
68
+ // ── Steps ─────────────────────────────────────────────────────────────────
54
69
  const steps = {
55
- encrypt: { label: "Encrypt", detail: ".env", status: "pending" },
56
- keys: { label: "Key", detail: ".env.keys", status: "pending" },
57
- gitignore:{ label: "Gitignore",detail: ".gitignore", status: "pending" },
58
- register: { label: "Register", detail: appName, status: "pending" },
59
- 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" },
60
75
  };
61
76
 
62
- function printSteps() {
77
+ const printSteps = () => {
63
78
  for (const s of Object.values(steps)) {
64
- const dot = s.status === "done" ? c.green("●")
65
- : s.status === "skip" ? c.dim("○")
66
- : s.status === "fail" ? c.red("●")
67
- : c.dim("○");
68
- const detail = s.status === "fail" ? c.red(s.detail) : c.dim(s.detail);
69
- const label = s.label.padEnd(11);
70
- const dots = c.dim(".".repeat(Math.max(2, 44 - label.length - s.detail.length)));
71
- const note = s.note ? " " + c.dim(s.note) : "";
72
- 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));
73
80
  }
74
- }
81
+ };
75
82
 
76
83
  // ── Encrypt ───────────────────────────────────────────────────────────────
84
+ steps.encrypt.status = "run";
85
+ steps.keys.status = "run";
77
86
  try {
78
87
  encryptDotenv(cwd);
79
- steps.encrypt.status = "done";
80
- steps.keys.status = "done";
88
+ steps.encrypt.status = "done";
89
+ steps.keys.status = "done";
81
90
  } catch (err) {
82
91
  steps.encrypt.status = "fail";
92
+ steps.keys.status = "fail";
83
93
  printSteps();
84
- console.log(HR);
85
- 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());
86
99
  process.exit(1);
87
100
  }
88
101
 
@@ -92,17 +105,22 @@ export const initCommand = new Command("init")
92
105
  } catch (err) {
93
106
  steps.keys.status = "fail";
94
107
  printSteps();
95
- console.log(HR);
96
- 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());
97
113
  process.exit(1);
98
114
  }
99
115
 
100
116
  // ── .gitignore ────────────────────────────────────────────────────────────
117
+ steps.gitignore.status = "run";
101
118
  const gitignoreResult = ensureGitignore(cwd);
102
119
  steps.gitignore.status = "done";
103
120
  steps.gitignore.note = gitignoreResult;
104
121
 
105
122
  // ── Register ──────────────────────────────────────────────────────────────
123
+ steps.register.status = "run";
106
124
  let res;
107
125
  try {
108
126
  res = await fetch(`${apiUrl}/cli/register-app`, {
@@ -113,8 +131,11 @@ export const initCommand = new Command("init")
113
131
  } catch (err) {
114
132
  steps.register.status = "fail";
115
133
  printSteps();
116
- console.log(HR);
117
- 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());
118
139
  process.exit(1);
119
140
  }
120
141
 
@@ -123,14 +144,19 @@ export const initCommand = new Command("init")
123
144
  try { body = await res.json(); } catch { /* ignore */ }
124
145
  steps.register.status = "fail";
125
146
  printSteps();
126
- console.log(HR);
147
+ console.log(ui.blank());
148
+ console.log(ui.hr());
149
+ console.log(ui.blank());
127
150
  if (res.status === 409) {
128
151
  const canonical = body?.existing_name ?? appName;
129
- console.error("\n " + c.red("✗") + ` An app named "${c.cyan(String(canonical))}" is already registered (case-insensitive match).`);
130
- console.error(" If you lost .suron.json, run: " + c.bold("suron recover --name " + String(canonical)) + "\n");
152
+ console.error(ui.errBlock(
153
+ `"${canonical}" already registered`,
154
+ `to restore a lost config › suron recover --name ${canonical}`
155
+ ));
131
156
  } else {
132
- console.error("\n " + c.red("✗") + ` ${body?.error ?? `register-app failed (${res.status})`}\n`);
157
+ console.error(ui.errBlock(body?.error ?? `register failed (${res.status})`));
133
158
  }
159
+ console.log(ui.blank());
134
160
  process.exit(1);
135
161
  }
136
162
 
@@ -147,6 +173,7 @@ export const initCommand = new Command("init")
147
173
  // ── Install SDK ───────────────────────────────────────────────────────────
148
174
  const pkgJsonPath = join(cwd, "package.json");
149
175
  let isEsm = false;
176
+ steps.sdk.status = "run";
150
177
 
151
178
  if (existsSync(pkgJsonPath)) {
152
179
  let alreadyInstalled = false;
@@ -175,51 +202,41 @@ export const initCommand = new Command("init")
175
202
  }
176
203
 
177
204
  printSteps();
178
- console.log(HR);
205
+ console.log(ui.blank());
206
+ console.log(ui.hr());
207
+ console.log(ui.blank());
179
208
 
180
209
  // ── Patch entry point ─────────────────────────────────────────────────────
181
210
  await patchEntryPoint(cwd, isEsm);
182
211
 
183
212
  // ── Done ──────────────────────────────────────────────────────────────────
184
- console.log();
185
- console.log(" " + c.dim("◇") + " .env encrypted " + c.dim("safe to commit"));
186
- console.log(" " + c.dim("◇") + " .env.keys " + c.dim("gitignored, keep it safe"));
187
- console.log(" " + c.dim("◇") + " .suron.json " + c.dim("safe to commit"));
188
- console.log(" " + c.dim("◆") + " " + c.bold("ready"));
189
- 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());
190
219
  });
191
220
 
192
- /**
193
- * Ensures standard entries are in .gitignore.
194
- * Creates the file if it doesn't exist.
195
- * @param {string} cwd
196
- * @returns {string} short note for the steps display
197
- */
198
- const GITIGNORE_ENTRIES = [
199
- "node_modules/",
200
- "package-lock.json",
201
- ".env.keys",
202
- ];
221
+ // ── Helpers ──────────────────────────────────────────────────────────────────
222
+
223
+ const GITIGNORE_ENTRIES = ["node_modules/", "package-lock.json", ".env.keys"];
203
224
 
204
225
  function ensureGitignore(cwd) {
205
226
  const gitignorePath = join(cwd, ".gitignore");
206
-
207
227
  if (!existsSync(gitignorePath)) {
208
228
  writeFileSync(gitignorePath, GITIGNORE_ENTRIES.join("\n") + "\n", "utf-8");
209
229
  return "created";
210
230
  }
211
-
212
231
  const content = readFileSync(gitignorePath, "utf-8");
213
232
  const existing = new Set(content.split("\n").map(l => l.trim()));
214
233
  const missing = GITIGNORE_ENTRIES.filter(e => !existing.has(e));
215
- if (missing.length === 0) return "already set";
216
-
217
- const separator = content.endsWith("\n") ? "" : "\n";
218
- 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");
219
237
  return "updated";
220
238
  }
221
239
 
222
- /** @param {string} cwd @returns {string} */
223
240
  function detectPackageManager(cwd) {
224
241
  if (existsSync(join(cwd, "pnpm-lock.yaml"))) return "pnpm";
225
242
  if (existsSync(join(cwd, "yarn.lock"))) return "yarn";
@@ -227,15 +244,12 @@ function detectPackageManager(cwd) {
227
244
  return "npm";
228
245
  }
229
246
 
230
- /** @param {string} pm @returns {string} */
231
247
  function pmAddCmd(pm) {
232
248
  return pm === "npm" ? "install" : "add";
233
249
  }
234
250
 
235
251
  /**
236
- * Strips everything except letters and digits while preserving the original case.
237
- * The backend stores names case-sensitively for display but compares lowercase
238
- * for uniqueness, so "DataHaven" and "datahaven" are the same app.
252
+ * Strips everything except letters and digits while preserving case.
239
253
  * @param {string} name
240
254
  * @returns {string}
241
255
  */
@@ -259,9 +273,6 @@ async function patchEntryPoint(cwd, isEsm) {
259
273
  }
260
274
 
261
275
  if (!entryPath) {
262
- console.log(" " + c.dim("▎"));
263
- console.log(" " + c.dim("▎") + " Add to your app entry point:");
264
- console.log(" " + c.dim("▎"));
265
276
  printSnippet(isEsm);
266
277
  return;
267
278
  }
@@ -270,8 +281,6 @@ async function patchEntryPoint(cwd, isEsm) {
270
281
  try { src = readFileSync(entryPath, "utf-8"); } catch { return; }
271
282
 
272
283
  const lines = src.split("\n");
273
-
274
- // Pass 1 — lines containing "dotenv"
275
284
  const toReplace = [];
276
285
  const seenIndices = new Set();
277
286
 
@@ -295,7 +304,6 @@ async function patchEntryPoint(cwd, isEsm) {
295
304
  }
296
305
  }
297
306
 
298
- // Pass 2 — bare config() call (ESM only — import and call are on separate lines)
299
307
  if (isEsm) {
300
308
  for (let i = 0; i < lines.length; i++) {
301
309
  if (seenIndices.has(i)) continue;
@@ -313,9 +321,6 @@ async function patchEntryPoint(cwd, isEsm) {
313
321
  }
314
322
 
315
323
  if (toReplace.length === 0) {
316
- console.log(" " + c.dim("▎"));
317
- console.log(" " + c.dim("▎") + " Add to your app entry point:");
318
- console.log(" " + c.dim("▎"));
319
324
  printSnippet(isEsm);
320
325
  return;
321
326
  }
@@ -323,46 +328,42 @@ async function patchEntryPoint(cwd, isEsm) {
323
328
  const relEntry = relative(cwd, entryPath);
324
329
 
325
330
  // ── Diff preview ───────────────────────────────────────────────────────────
326
- console.log(" " + c.dim("▎"));
327
- console.log(" " + c.dim("▎ ") + c.dim(relEntry));
328
- console.log(" " + c.dim("▎"));
331
+ console.log(ui.kv("ENTRY", relEntry, 8));
332
+ console.log(ui.blank());
329
333
 
330
334
  for (const { content, replacement } of toReplace) {
331
- console.log(" " + c.dim("") + c.red("⁻") + " " + c.dim(content.trim()));
335
+ console.log(ui.diff("remove", content.trim()));
332
336
  if (replacement !== null) {
333
337
  for (const l of replacement.split("\n")) {
334
- console.log(" " + c.dim("") + c.green("⁺") + " " + c.cyan(l.trim()));
338
+ console.log(ui.diff("add", l.trim()));
335
339
  }
336
340
  } else {
337
- console.log(" " + c.dim("") + c.yellow("?") + " " + c.dim("(no automatic replacement — update manually)"));
341
+ console.log(ui.diff("unknown", ""));
338
342
  }
339
- console.log(" " + c.dim("▎"));
343
+ console.log(ui.blank());
340
344
  }
341
345
 
342
- 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("");
343
348
  const confirmed = answer === "" || /^y(es)?$/i.test(answer);
344
- console.log(HR);
349
+ console.log(ui.blank());
350
+ console.log(ui.hr());
351
+ console.log(ui.blank());
345
352
 
346
353
  if (!confirmed) {
347
- console.log();
348
- console.log(" " + c.dim("skipped — add manually:"));
349
- console.log();
350
354
  printSnippet(isEsm);
351
355
  return;
352
356
  }
353
357
 
354
- // Apply
355
358
  const indexMap = new Map(toReplace.map(r => [r.index, r]));
356
359
  const outLines = lines.map((line, i) => {
357
360
  const r = indexMap.get(i);
358
361
  return (r && r.replacement !== null) ? r.replacement : line;
359
362
  });
360
363
 
361
- // Move await config() to right after last import block (ESM)
362
364
  if (isEsm) {
363
- const callLineIndex = outLines.findIndex(l => /^\s*await config\s*\(/.test(l));
365
+ const callLineIndex = outLines.findIndex(l => /^\s*await config\s*\(/.test(l));
364
366
  const lastImportIndex = outLines.reduce((last, l, i) => l.trimStart().startsWith("import ") ? i : last, -1);
365
-
366
367
  if (callLineIndex !== -1 && callLineIndex > lastImportIndex + 2) {
367
368
  const callLine = outLines[callLineIndex];
368
369
  outLines.splice(callLineIndex, 1);
@@ -375,20 +376,22 @@ async function patchEntryPoint(cwd, isEsm) {
375
376
  try {
376
377
  writeFileSync(entryPath, outLines.join("\n"), "utf-8");
377
378
  } catch (err) {
378
- console.error(" " + c.red("✗") + " Could not write " + relEntry + ": " + err.message);
379
+ console.error(ui.errBlock("could not write " + relEntry, err.message));
379
380
  printSnippet(isEsm);
380
381
  }
381
382
  }
382
383
 
383
- /** @param {boolean} isEsm */
384
384
  function printSnippet(isEsm) {
385
+ console.log(ui.infoBlock("add to your entry point:"));
386
+ console.log(ui.blank());
385
387
  if (isEsm) {
386
- console.log(" " + c.dim("") + c.cyan("import") + " { config } from " + c.green("'@suronai/sdk'"));
387
- 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()"));
388
390
  } else {
389
- console.log(" " + c.dim("") + "const { config } = " + c.cyan("require") + "(" + c.green("'@suronai/sdk'") + ")");
390
- 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()"));
391
393
  }
392
- console.log(" " + c.dim("▎"));
393
- console.log(HR);
394
+ console.log(ui.blank());
395
+ console.log(ui.hr());
396
+ console.log(ui.blank());
394
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,38 +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
- // Names are case-insensitive for lookup — "datahaven" finds "DataHaven".
27
- // We preserve the input casing here; the backend returns the canonical name.
28
35
  let appName;
29
36
  if (opts.name) {
30
- // Strip non-alphanumeric but preserve case — backend does case-insensitive match.
31
37
  appName = opts.name.replace(/[^a-zA-Z0-9]/g, "");
32
- console.log(" App name " + c.cyan(appName));
33
38
  } else {
34
39
  const suggested = sanitiseName(basename(cwd) || "myapp");
35
- const raw = await prompt(` App name [${c.dim(suggested)}] `);
40
+ process.stdout.write(ui.promptLine("app name [" + suggested + "]"));
41
+ const raw = await prompt("");
36
42
  appName = sanitiseName(raw || suggested);
37
43
  }
38
44
 
39
45
  if (!appName) {
40
- console.error(" " + c.red("✗") + " App name is required and must contain at least one letter or digit.\n");
46
+ console.log(ui.blank());
47
+ console.error(ui.errBlock("app name required"));
48
+ console.log(ui.blank());
41
49
  process.exit(1);
42
50
  }
43
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
+
44
58
  // ── Look up app ───────────────────────────────────────────────────────────
45
- console.log("\n " + c.dim("Looking up app..."));
59
+ process.stdout.write(ui.INDENT + ui.slate("querying ···"));
46
60
 
47
61
  let res;
48
62
  try {
@@ -52,44 +66,47 @@ export const recoverCommand = new Command("recover")
52
66
  body: JSON.stringify({ name: appName }),
53
67
  });
54
68
  } catch (err) {
55
- 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());
56
72
  process.exit(1);
57
73
  }
58
74
 
75
+ process.stdout.write("\r" + " ".repeat(40) + "\r");
76
+
59
77
  if (!res.ok) {
60
78
  let body = {};
61
79
  try { body = await res.json(); } catch { /* ignore */ }
62
80
  if (res.status === 404) {
63
- console.error("\n " + c.red("✗") + ` No app named "${c.cyan(appName)}" found.`);
64
- console.error(" Lookup is case-insensitive — check spelling (letters and numbers only).\n");
81
+ console.error(ui.errBlock(
82
+ `"${appName}" not found`,
83
+ "lookup is case-insensitive — check spelling"
84
+ ));
65
85
  } else {
66
- console.error("\n " + c.red("✗") + ` ${body?.error ?? `recover-app failed (${res.status})`}\n`);
86
+ console.error(ui.errBlock(body?.error ?? `recover failed (${res.status})`));
67
87
  }
88
+ console.log(ui.blank());
68
89
  process.exit(1);
69
90
  }
70
91
 
71
92
  const { app_id, name } = await res.json();
72
93
 
73
94
  // ── Write .suron.json ─────────────────────────────────────────────────────
74
- // Use the canonical name returned by the server (original registration casing).
75
95
  writeFileSync(
76
96
  join(cwd, ".suron.json"),
77
97
  JSON.stringify({ app: name, id: app_id, api_url: apiUrl }, null, 2) + "\n",
78
98
  "utf-8"
79
99
  );
80
100
 
81
- console.log();
82
- console.log(" " + c.green("✓") + " .suron.json restored");
83
- console.log(" " + c.dim(" app: ") + c.cyan(name));
84
- console.log(" " + c.dim(" id: ") + c.cyan(app_id));
85
- 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());
86
108
  });
87
109
 
88
- /**
89
- * Strips non-alphanumeric characters while preserving case.
90
- * @param {string} name
91
- * @returns {string}
92
- */
93
110
  function sanitiseName(name) {
94
111
  return name.replace(/[^a-zA-Z0-9]/g, "");
95
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
-