@suronai/cli 0.1.30 → 0.1.32

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,7 +1,7 @@
1
1
  {
2
2
  "name": "@suronai/cli",
3
- "version": "0.1.30",
4
- "description": "CLI for Suron — suron login, init, whoami, rotate",
3
+ "version": "0.1.32",
4
+ "description": "CLI for Suron — suron login, init, whoami, recover",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "suron": "./src/index.js"
@@ -10,8 +10,8 @@
10
10
  "src"
11
11
  ],
12
12
  "scripts": {
13
- "build": "node --check src/index.js src/commands/login.js src/commands/init.js src/commands/rotate.js src/commands/whoami.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/rotate.js src/commands/whoami.js src/utils/config.js src/utils/dotenvx.js src/utils/colors.js"
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
15
  },
16
16
  "dependencies": {
17
17
  "@dotenvx/dotenvx": "latest",
@@ -15,6 +15,12 @@ export const initCommand = new Command("init")
15
15
 
16
16
  console.log("\n" + c.bold(" suron init") + " — " + c.dim(cwd) + "\n");
17
17
 
18
+ if (existsSync(join(cwd, ".suron.json"))) {
19
+ console.error(" " + c.red("✗") + " .suron.json already exists — this app is already initialised.");
20
+ console.error(" To restore a lost config, run: " + c.bold("suron recover") + "\n");
21
+ process.exit(1);
22
+ }
23
+
18
24
  if (!existsSync(join(cwd, ".env"))) {
19
25
  console.error(" " + c.red("✗") + " .env not found in current directory");
20
26
  console.error(" Create a .env file with your secrets, then run: suron init\n");
@@ -67,17 +73,23 @@ export const initCommand = new Command("init")
67
73
  }
68
74
 
69
75
  if (!res.ok) {
70
- let errMsg = `register-app failed (${res.status})`;
71
- try {
72
- const body = await res.json();
73
- if (body?.error) errMsg = body.error;
74
- } catch { /* ignore */ }
75
- console.error("\n " + c.red("") + ` ${errMsg}\n`);
76
+ let body = {};
77
+ try { body = await res.json(); } catch { /* ignore */ }
78
+
79
+ if (res.status === 409) {
80
+ console.error("\n " + c.red("✗") + ` An app named "${c.cyan(appName)}" is already registered.`);
81
+ console.error(" If you lost .suron.json, run: " + c.bold("suron recover") + "\n");
82
+ } else {
83
+ console.error("\n " + c.red("✗") + ` ${body?.error ?? `register-app failed (${res.status})`}\n`);
84
+ }
76
85
  process.exit(1);
77
86
  }
78
87
 
79
88
  const { app_id } = await res.json();
80
89
 
90
+ // ── .gitignore ─────────────────────────────────────────────────────────────
91
+ ensureGitignore(cwd);
92
+
81
93
  // ── Write .suron.json ─────────────────────────────────────────────────────
82
94
  writeFileSync(
83
95
  join(cwd, ".suron.json"),
@@ -105,6 +117,8 @@ export const initCommand = new Command("init")
105
117
  } catch {
106
118
  console.error(" " + c.yellow("⚠") + " SDK install failed — run manually: npm install @suronai/sdk");
107
119
  }
120
+ } else {
121
+ console.log(" " + c.dim("@suronai/sdk already installed — skipped."));
108
122
  }
109
123
  }
110
124
 
@@ -114,12 +128,41 @@ export const initCommand = new Command("init")
114
128
  // ── Done ──────────────────────────────────────────────────────────────────
115
129
  console.log();
116
130
  console.log(" " + c.green("✓") + " .env encrypted " + c.dim("(safe to commit)"));
117
- console.log(" " + c.green("✓") + " .env.keys kept " + c.dim("(add to .gitignore)"));
131
+ console.log(" " + c.green("✓") + " .env.keys kept " + c.dim("(gitignored)") );
118
132
  console.log(" " + c.green("✓") + " .suron.json written " + c.dim("(safe to commit)"));
119
133
  console.log(" " + c.green("✓") + " @suronai/sdk installed");
120
134
  console.log();
121
135
  });
122
136
 
137
+ /**
138
+ * Ensures .env.keys is in .gitignore.
139
+ * Creates .gitignore if it doesn't exist.
140
+ * Appends the entry if it's not already present (exact line match).
141
+ * @param {string} cwd
142
+ */
143
+ const GITIGNORE_ENTRIES = [
144
+ "node_modules/",
145
+ "package-lock.json",
146
+ ".env.keys",
147
+ ];
148
+
149
+ function ensureGitignore(cwd) {
150
+ const gitignorePath = join(cwd, ".gitignore");
151
+
152
+ if (!existsSync(gitignorePath)) {
153
+ writeFileSync(gitignorePath, GITIGNORE_ENTRIES.join("\n") + "\n", "utf-8");
154
+ return;
155
+ }
156
+
157
+ const content = readFileSync(gitignorePath, "utf-8");
158
+ const existing = new Set(content.split("\n").map(l => l.trim()));
159
+ const missing = GITIGNORE_ENTRIES.filter(e => !existing.has(e));
160
+ if (missing.length === 0) return;
161
+
162
+ const separator = content.endsWith("\n") ? "" : "\n";
163
+ writeFileSync(gitignorePath, content + separator + missing.join("\n") + "\n", "utf-8");
164
+ }
165
+
123
166
  /** @param {string} cwd @returns {string} */
124
167
  function detectPackageManager(cwd) {
125
168
  if (existsSync(join(cwd, "pnpm-lock.yaml"))) return "pnpm";
@@ -176,15 +219,53 @@ async function patchEntryPoint(cwd, isEsm) {
176
219
 
177
220
  const lines = src.split("\n");
178
221
 
179
- // Find every line that references dotenv (import or require, any style)
180
- const dotenvLines = [];
222
+ // Two-pass scan:
223
+ // Pass 1 lines containing "dotenv" (import or require)
224
+ // Pass 2 — bare config() / config({...}) call lines that don't contain "dotenv"
225
+ // (these appear after the import was already on a separate line)
226
+ const toReplace = [];
227
+ const seenIndices = new Set();
228
+
181
229
  for (let i = 0; i < lines.length; i++) {
230
+ const trimmed = lines[i].trim();
231
+ const indent = lines[i].match(/^(\s*)/)[1];
232
+
182
233
  if (lines[i].includes("dotenv")) {
183
- dotenvLines.push({ index: i, content: lines[i] });
234
+ seenIndices.add(i);
235
+ let replacement = null;
236
+ if (isEsm) {
237
+ if (trimmed.startsWith("import")) {
238
+ replacement = indent + trimmed.replace(/(from\s+)['"]dotenv(?:\/config)?['"]/, '$1"@suronai/sdk"');
239
+ }
240
+ } else {
241
+ if (trimmed.includes("require")) {
242
+ replacement = indent + "const { config } = require(\"@suronai/sdk\");\n" + indent + "await config();";
243
+ }
244
+ }
245
+ toReplace.push({ index: i, content: lines[i], replacement });
184
246
  }
185
247
  }
186
248
 
187
- if (dotenvLines.length === 0) {
249
+ // Pass 2: bare config() call — only in ESM where the import and call are separate lines
250
+ if (isEsm) {
251
+ for (let i = 0; i < lines.length; i++) {
252
+ if (seenIndices.has(i)) continue;
253
+ const trimmed = lines[i].trim();
254
+ const indent = lines[i].match(/^(\s*)/)[1];
255
+ // Matches: config() / config({}) / config({ ... }) — with or without semicolon
256
+ if (/^config\s*\(.*\);?$/.test(trimmed) && !trimmed.startsWith("//")) {
257
+ toReplace.push({
258
+ index: i,
259
+ content: lines[i],
260
+ replacement: indent + trimmed.replace(/^config\s*\(/, "await config("),
261
+ });
262
+ }
263
+ }
264
+ // Sort by line number so the diff preview is in file order
265
+ toReplace.sort((a, b) => a.index - b.index);
266
+ }
267
+
268
+ if (toReplace.length === 0) {
188
269
  console.log("\n Add to your app entry point:\n");
189
270
  printSnippet(isEsm);
190
271
  return;
@@ -195,36 +276,7 @@ async function patchEntryPoint(cwd, isEsm) {
195
276
  console.log(" " + c.yellow("▶") + " Found dotenv in " + c.dim(relEntry) + ":");
196
277
  console.log();
197
278
 
198
- // For each dotenv line, compute its replacement.
199
- // We preserve the original line's leading whitespace (indent) and only
200
- // swap out the dotenv-specific part — nothing else on the line is touched.
201
- const replacements = dotenvLines.map(({ index, content }) => {
202
- const trimmed = content.trim();
203
- const indent = content.match(/^(\s*)/)[1];
204
- let replacement = null;
205
-
206
- if (isEsm) {
207
- // import { config } from "dotenv" → import { config } from "@suronai/sdk"
208
- // Preserves the quote style and anything else on the line.
209
- if (trimmed.startsWith("import") && trimmed.includes("dotenv")) {
210
- replacement = indent + trimmed.replace(/(from\s+)['"]dotenv['"]/, '$1"@suronai/sdk"');
211
- }
212
- // config(); → await config();
213
- // Only matches lines that are purely a config() call, nothing else.
214
- else if (/^config\s*\(\s*\);?$/.test(trimmed)) {
215
- replacement = indent + trimmed.replace(/^config\s*\(/, "await config(");
216
- }
217
- } else {
218
- // require("dotenv").config() → const { config } = require("@suronai/sdk"); await config();
219
- if (trimmed.includes("require") && trimmed.includes("dotenv")) {
220
- replacement = indent + "const { config } = require(\"@suronai/sdk\");\n" + indent + "await config();";
221
- }
222
- }
223
-
224
- // If we found a dotenv line but don't know how to replace it, show it
225
- // with a warning so the user knows to handle it manually.
226
- return { index, content, replacement };
227
- });
279
+ const replacements = toReplace;
228
280
 
229
281
  // Show the diff preview
230
282
  for (const { content, replacement } of replacements) {
@@ -0,0 +1,82 @@
1
+ import { Command } from "commander";
2
+ import { existsSync, writeFileSync } from "fs";
3
+ import { join, basename } from "path";
4
+ import { requireApiUrl, prompt } from "../utils/config.js";
5
+ import c from "../utils/colors.js";
6
+
7
+ export const recoverCommand = new Command("recover")
8
+ .description("Restore a lost .suron.json by looking up your app name")
9
+ .option("--name <n>", "App name to recover (skips interactive prompt)")
10
+ .action(async (opts) => {
11
+ const cwd = process.cwd();
12
+ const apiUrl = requireApiUrl();
13
+
14
+ console.log("\n" + c.bold(" suron recover") + " — " + c.dim(cwd) + "\n");
15
+
16
+ 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] › ");
19
+ if (!/^y(es)?$/i.test(answer)) {
20
+ console.log(" " + c.dim("Cancelled.\n"));
21
+ process.exit(0);
22
+ }
23
+ }
24
+
25
+ // ── App name ──────────────────────────────────────────────────────────────
26
+ let appName;
27
+ if (opts.name) {
28
+ appName = opts.name.toLowerCase().replace(/[^a-z0-9]/g, "");
29
+ console.log(" App name " + c.cyan(appName));
30
+ } 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, "");
34
+ }
35
+
36
+ if (!appName) {
37
+ console.error(" " + c.red("✗") + " App name is required.\n");
38
+ process.exit(1);
39
+ }
40
+
41
+ // ── Look up app ───────────────────────────────────────────────────────────
42
+ console.log("\n " + c.dim("Looking up app..."));
43
+
44
+ let res;
45
+ try {
46
+ res = await fetch(`${apiUrl}/cli/recover-app`, {
47
+ method: "POST",
48
+ headers: { "Content-Type": "application/json" },
49
+ body: JSON.stringify({ name: appName }),
50
+ });
51
+ } catch (err) {
52
+ console.error("\n " + c.red("✗") + ` Could not reach Suron API: ${err.message}\n`);
53
+ process.exit(1);
54
+ }
55
+
56
+ if (!res.ok) {
57
+ let body = {};
58
+ try { body = await res.json(); } catch { /* ignore */ }
59
+ 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");
62
+ } else {
63
+ console.error("\n " + c.red("✗") + ` ${body?.error ?? `recover-app failed (${res.status})`}\n`);
64
+ }
65
+ process.exit(1);
66
+ }
67
+
68
+ const { app_id, name } = await res.json();
69
+
70
+ // ── Write .suron.json ─────────────────────────────────────────────────────
71
+ writeFileSync(
72
+ join(cwd, ".suron.json"),
73
+ JSON.stringify({ app: name, id: app_id, api_url: apiUrl }, null, 2) + "\n",
74
+ "utf-8"
75
+ );
76
+
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();
82
+ });
package/src/index.js CHANGED
@@ -2,10 +2,10 @@
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 { rotateCommand } from "./commands/rotate.js";
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";
9
9
 
10
10
  const require = createRequire(import.meta.url);
11
11
  const { version } = require("../package.json");
@@ -20,6 +20,6 @@ program
20
20
  program.addCommand(loginCommand);
21
21
  program.addCommand(initCommand);
22
22
  program.addCommand(whoamiCommand);
23
- program.addCommand(rotateCommand);
23
+ program.addCommand(recoverCommand);
24
24
 
25
25
  program.parse(process.argv);
@@ -1,80 +0,0 @@
1
- import { Command } from "commander";
2
- import { existsSync, readFileSync, unlinkSync } from "fs";
3
- import { join } from "path";
4
- import { requireApiUrl } from "../utils/config.js";
5
- import { encryptDotenv, readPrivateKey } from "../utils/dotenvx.js";
6
- import c from "../utils/colors.js";
7
-
8
- export const rotateCommand = new Command("rotate")
9
- .description("Re-encrypt .env with a new private key and update Suron")
10
- .action(async () => {
11
- const cwd = process.cwd();
12
- const apiUrl = requireApiUrl();
13
-
14
- console.log("\n" + c.bold(" suron rotate") + " — " + c.dim(cwd) + "\n");
15
-
16
- const suronJsonPath = join(cwd, ".suron.json");
17
- if (!existsSync(suronJsonPath)) {
18
- console.error(" " + c.red("✗") + " .suron.json not found — run: suron init\n");
19
- process.exit(1);
20
- }
21
-
22
- let suronConfig;
23
- try {
24
- suronConfig = JSON.parse(readFileSync(suronJsonPath, "utf-8"));
25
- } catch {
26
- console.error(" " + c.red("✗") + " .suron.json is malformed\n");
27
- process.exit(1);
28
- }
29
-
30
- if (!existsSync(join(cwd, ".env"))) {
31
- console.error(" " + c.red("✗") + " .env not found\n");
32
- process.exit(1);
33
- }
34
-
35
- console.log(" " + c.dim("Re-encrypting .env with a new private key..."));
36
-
37
- try {
38
- encryptDotenv(cwd);
39
- } catch (err) {
40
- console.error("\n " + c.red("✗") + " " + err.message + "\n");
41
- process.exit(1);
42
- }
43
-
44
- let newPrivateKey;
45
- try {
46
- newPrivateKey = readPrivateKey(cwd);
47
- } catch (err) {
48
- console.error("\n " + c.red("✗") + " " + err.message + "\n");
49
- process.exit(1);
50
- }
51
-
52
- console.log(" " + c.dim("Updating private key in Suron..."));
53
-
54
- let res;
55
- try {
56
- res = await fetch(`${apiUrl}/cli/rotate-key`, {
57
- method: "POST",
58
- headers: { "Content-Type": "application/json" },
59
- body: JSON.stringify({ app_id: suronConfig.id, private_key: newPrivateKey }),
60
- });
61
- } catch (err) {
62
- console.error("\n " + c.red("✗") + ` Could not reach Suron API: ${err.message}\n`);
63
- process.exit(1);
64
- }
65
-
66
- if (!res.ok) {
67
- const text = await res.text().catch(() => "");
68
- console.error("\n " + c.red("✗") + ` rotate-key failed (${res.status}): ${text}\n`);
69
- process.exit(1);
70
- }
71
-
72
- const keysPath = join(cwd, ".env.keys");
73
- if (existsSync(keysPath)) unlinkSync(keysPath);
74
-
75
- console.log();
76
- console.log(" " + c.green("✓") + " Key rotated");
77
- console.log(" " + c.green("✓") + " .env re-encrypted " + c.dim("(safe to commit)"));
78
- console.log(" " + c.green("✓") + " .env.keys deleted");
79
- console.log();
80
- });