@suronai/cli 0.1.39 → 0.1.41

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.39",
3
+ "version": "0.1.41",
4
4
  "description": "Suron CLI — secrets delivery",
5
5
  "type": "module",
6
6
  "bin": {
package/src/commands.js CHANGED
@@ -1,19 +1,235 @@
1
1
  import { Command } from "commander";
2
- import { intro, outro, spinner, select, text, isCancel } from "@clack/prompts";
2
+ import { spinner, select, text, isCancel } from "@clack/prompts";
3
3
  import { existsSync, readFileSync, writeFileSync } from "fs";
4
- import { join } from "path";
5
- import { exec } from "child_process";
4
+ import { join, basename, relative } from "path";
5
+ import { spawn, execSync } from "child_process";
6
6
  import ui from "./utils/colors.js";
7
7
  import { getToken, requireToken, saveConfig, clearToken } from "./utils/config.js";
8
8
  import { api, BASE_URL } from "./utils/api.js";
9
9
 
10
- function cancel(msg) {
10
+ // ── Shared helpers ────────────────────────────────────────────────────────────
11
+
12
+ function cancel(msg, hint) {
11
13
  console.log(ui.blank());
12
- console.log(ui.errBlock(msg));
14
+ console.log(ui.errBlock(msg, hint));
13
15
  console.log(ui.blank());
14
16
  process.exit(1);
15
17
  }
16
18
 
19
+ function sanitiseName(name) {
20
+ return name.replace(/[^a-zA-Z0-9]/g, "");
21
+ }
22
+
23
+ function openBrowser(url) {
24
+ if (process.platform === "win32") {
25
+ spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" }).unref();
26
+ } else if (process.platform === "darwin") {
27
+ spawn("open", [url], { detached: true, stdio: "ignore" }).unref();
28
+ } else {
29
+ spawn("xdg-open", [url], { detached: true, stdio: "ignore" }).unref();
30
+ }
31
+ }
32
+
33
+ function waitForEnter(prompt) {
34
+ process.stdout.write(prompt);
35
+ return new Promise(resolve => {
36
+ const handler = (buf) => {
37
+ if (buf.toString().includes("\n")) {
38
+ process.stdin.removeListener("data", handler);
39
+ process.stdin.pause();
40
+ resolve();
41
+ }
42
+ };
43
+ process.stdin.resume();
44
+ process.stdin.setEncoding("utf-8");
45
+ process.stdin.on("data", handler);
46
+ });
47
+ }
48
+
49
+ function detectPackageManager(cwd) {
50
+ if (existsSync(join(cwd, "bun.lockb")) || existsSync(join(cwd, "bun.lock"))) return "bun";
51
+ if (existsSync(join(cwd, "pnpm-lock.yaml"))) return "pnpm";
52
+ if (existsSync(join(cwd, "yarn.lock"))) return "yarn";
53
+ return "npm";
54
+ }
55
+
56
+ function pmAddCmd(pm) {
57
+ return pm === "npm" ? "install" : "add";
58
+ }
59
+
60
+ // ── .gitignore ────────────────────────────────────────────────────────────────
61
+
62
+ const GITIGNORE_ENTRIES = [".env", ".suron.json"];
63
+
64
+ function ensureGitignore(cwd) {
65
+ const gitignorePath = join(cwd, ".gitignore");
66
+ if (!existsSync(gitignorePath)) {
67
+ writeFileSync(gitignorePath, GITIGNORE_ENTRIES.join("\n") + "\n", "utf-8");
68
+ return "created";
69
+ }
70
+ const content = readFileSync(gitignorePath, "utf-8");
71
+ const existing = new Set(content.split("\n").map(l => l.trim()));
72
+ const missing = GITIGNORE_ENTRIES.filter(e => !existing.has(e));
73
+ if (missing.length === 0) return "ok";
74
+ const sep = content.endsWith("\n") ? "" : "\n";
75
+ writeFileSync(gitignorePath, content + sep + missing.join("\n") + "\n", "utf-8");
76
+ return `added ${missing.join(", ")}`;
77
+ }
78
+
79
+ // ── SDK install ───────────────────────────────────────────────────────────────
80
+
81
+ function installSdk(cwd) {
82
+ const pkgJsonPath = join(cwd, "package.json");
83
+ if (!existsSync(pkgJsonPath)) return { status: "skip", note: "no package.json" };
84
+
85
+ let alreadyInstalled = false;
86
+ let isEsm = false;
87
+ try {
88
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, "utf-8"));
89
+ alreadyInstalled = !!(pkg.dependencies?.["@suronai/sdk"] || pkg.devDependencies?.["@suronai/sdk"]);
90
+ isEsm = pkg.type === "module";
91
+ } catch {
92
+ return { status: "skip", note: "could not read package.json" };
93
+ }
94
+
95
+ if (alreadyInstalled) return { status: "skip", note: "already installed", isEsm };
96
+
97
+ const pm = detectPackageManager(cwd);
98
+ try {
99
+ execSync(`${pm} ${pmAddCmd(pm)} @suronai/sdk`, { cwd, stdio: "pipe" });
100
+ return { status: "done", isEsm };
101
+ } catch {
102
+ return { status: "fail", note: `run: npm install @suronai/sdk`, isEsm };
103
+ }
104
+ }
105
+
106
+ // ── Entry-point patching ──────────────────────────────────────────────────────
107
+
108
+ async function patchEntryPoint(cwd, isEsm) {
109
+ const candidates = isEsm
110
+ ? ["index.js", "index.mjs", "src/index.js", "src/index.mjs"]
111
+ : ["index.js", "index.cjs", "src/index.js", "src/index.cjs"];
112
+
113
+ let entryPath = null;
114
+ for (const rel of candidates) {
115
+ const abs = join(cwd, rel);
116
+ if (existsSync(abs)) { entryPath = abs; break; }
117
+ }
118
+
119
+ if (!entryPath) { printSnippet(isEsm); return; }
120
+
121
+ let src;
122
+ try { src = readFileSync(entryPath, "utf-8"); } catch { return; }
123
+
124
+ const lines = src.split("\n");
125
+ const toReplace = [];
126
+ const seenIndices = new Set();
127
+
128
+ for (let i = 0; i < lines.length; i++) {
129
+ const trimmed = lines[i].trim();
130
+ const indent = lines[i].match(/^(\s*)/)[1];
131
+
132
+ if (lines[i].includes("dotenv")) {
133
+ seenIndices.add(i);
134
+ let replacement = null;
135
+ if (isEsm) {
136
+ if (trimmed.startsWith("import")) {
137
+ replacement = indent + trimmed.replace(/(from\s+)['"]dotenv(?:\/config)?['"]/, '$1"@suronai/sdk"');
138
+ }
139
+ } else {
140
+ if (trimmed.includes("require")) {
141
+ replacement = indent + 'const { config } = require("@suronai/sdk");\n' + indent + "await config();";
142
+ }
143
+ }
144
+ toReplace.push({ index: i, content: lines[i], replacement });
145
+ }
146
+ }
147
+
148
+ if (isEsm) {
149
+ for (let i = 0; i < lines.length; i++) {
150
+ if (seenIndices.has(i)) continue;
151
+ const trimmed = lines[i].trim();
152
+ const indent = lines[i].match(/^(\s*)/)[1];
153
+ if (/^config\s*\(.*\);?$/.test(trimmed) && !trimmed.startsWith("//")) {
154
+ toReplace.push({
155
+ index: i,
156
+ content: lines[i],
157
+ replacement: indent + trimmed.replace(/^config\s*\(/, "await config("),
158
+ });
159
+ }
160
+ }
161
+ toReplace.sort((a, b) => a.index - b.index);
162
+ }
163
+
164
+ if (toReplace.length === 0) { printSnippet(isEsm); return; }
165
+
166
+ const relEntry = relative(cwd, entryPath);
167
+
168
+ console.log(ui.kv("ENTRY", relEntry, 8));
169
+ console.log(ui.blank());
170
+
171
+ for (const { content, replacement } of toReplace) {
172
+ console.log(ui.diff("remove", content.trim()));
173
+ if (replacement !== null) {
174
+ for (const l of replacement.split("\n")) console.log(ui.diff("add", l.trim()));
175
+ } else {
176
+ console.log(ui.diff("unknown", ""));
177
+ }
178
+ console.log(ui.blank());
179
+ }
180
+
181
+ await waitForEnter(ui.promptLine("apply patch? [Y/n]"));
182
+ const answer = "";
183
+ const confirmed = answer === "" || /^y(es)?$/i.test(answer);
184
+ console.log(ui.blank());
185
+ console.log(ui.hr());
186
+ console.log(ui.blank());
187
+
188
+ if (!confirmed) { printSnippet(isEsm); return; }
189
+
190
+ const indexMap = new Map(toReplace.map(r => [r.index, r]));
191
+ const outLines = lines.map((line, i) => {
192
+ const r = indexMap.get(i);
193
+ return (r && r.replacement !== null) ? r.replacement : line;
194
+ });
195
+
196
+ if (isEsm) {
197
+ const callLineIndex = outLines.findIndex(l => /^\s*await config\s*\(/.test(l));
198
+ const lastImportIndex = outLines.reduce((last, l, i) => l.trimStart().startsWith("import ") ? i : last, -1);
199
+ if (callLineIndex !== -1 && callLineIndex > lastImportIndex + 2) {
200
+ const callLine = outLines[callLineIndex];
201
+ outLines.splice(callLineIndex, 1);
202
+ if (outLines[callLineIndex]?.trim() === "") outLines.splice(callLineIndex, 1);
203
+ const newLastImport = outLines.reduce((last, l, i) => l.trimStart().startsWith("import ") ? i : last, -1);
204
+ outLines.splice(newLastImport + 1, 0, "", callLine, "");
205
+ }
206
+ }
207
+
208
+ try {
209
+ writeFileSync(entryPath, outLines.join("\n"), "utf-8");
210
+ } catch (err) {
211
+ console.error(ui.errBlock("could not write " + relEntry, err.message));
212
+ printSnippet(isEsm);
213
+ }
214
+ }
215
+
216
+ function printSnippet(isEsm) {
217
+ console.log(ui.infoBlock("add to your entry point:"));
218
+ console.log(ui.blank());
219
+ if (isEsm) {
220
+ console.log(ui.INDENT + " " + ui.amber('import { config } from "@suronai/sdk"'));
221
+ console.log(ui.INDENT + " " + ui.amber("await config()"));
222
+ } else {
223
+ console.log(ui.INDENT + " " + ui.amber('const { config } = require("@suronai/sdk")'));
224
+ console.log(ui.INDENT + " " + ui.amber("await config()"));
225
+ }
226
+ console.log(ui.blank());
227
+ console.log(ui.hr());
228
+ console.log(ui.blank());
229
+ }
230
+
231
+ // ── Commands ──────────────────────────────────────────────────────────────────
232
+
17
233
  export const loginCommand = new Command("login")
18
234
  .description("Authenticate via browser")
19
235
  .option("--force", "Re-authenticate even if already logged in")
@@ -42,12 +258,14 @@ export const loginCommand = new Command("login")
42
258
 
43
259
  const loginUrl = `${BASE_URL}/clilogin?code=${code}`;
44
260
 
45
- console.log(ui.infoBlock("Opening browser to complete login..."));
261
+ console.log(ui.infoBlock("press Enter to open login in your browser"));
46
262
  console.log(ui.blank());
47
263
  console.log(ui.kv("URL", loginUrl, 6));
48
264
  console.log(ui.blank());
49
265
 
50
- exec(`open "${loginUrl}" 2>/dev/null || xdg-open "${loginUrl}" 2>/dev/null || cmd /c start "" "${loginUrl}" 2>/dev/null`);
266
+ await waitForEnter(ui.promptLine("open browser"));
267
+ openBrowser(loginUrl);
268
+ console.log(ui.blank());
51
269
 
52
270
  const s = spinner();
53
271
  s.start("Waiting for approval");
@@ -90,9 +308,7 @@ export const logoutCommand = new Command("logout")
90
308
  .description("Clear local session token")
91
309
  .action(async () => {
92
310
  clearToken();
93
- try {
94
- await api("/auth/logout", { method: "POST" });
95
- } catch {}
311
+ try { await api("/auth/logout", { method: "POST" }); } catch {}
96
312
  console.log(ui.blank());
97
313
  console.log(ui.okBlock("logged out"));
98
314
  console.log(ui.blank());
@@ -100,19 +316,21 @@ export const logoutCommand = new Command("logout")
100
316
 
101
317
  export const initCommand = new Command("init")
102
318
  .description("Initialize Suron in this project")
103
- .action(async () => {
104
- const token = requireToken();
319
+ .option("--name <n>", "App name (skips interactive prompt)")
320
+ .action(async (opts) => {
321
+ requireToken();
322
+ const cwd = process.cwd();
105
323
 
106
- if (existsSync(".suron.json")) {
324
+ if (existsSync(join(cwd, ".suron.json"))) {
107
325
  console.log(ui.blank());
108
326
  console.log(ui.errBlock(".suron.json already exists", "use suron up to push a new version"));
109
327
  console.log(ui.blank());
110
328
  process.exit(1);
111
329
  }
112
330
 
113
- if (!existsSync(".env")) {
331
+ if (!existsSync(join(cwd, ".env"))) {
114
332
  console.log(ui.blank());
115
- console.log(ui.errBlock(".env not found", "create a .env file first"));
333
+ console.log(ui.errBlock(".env not found", "create a .env file with your secrets first"));
116
334
  console.log(ui.blank());
117
335
  process.exit(1);
118
336
  }
@@ -122,40 +340,49 @@ export const initCommand = new Command("init")
122
340
  console.log(ui.hr());
123
341
  console.log(ui.blank());
124
342
 
343
+ // ── App selection ─────────────────────────────────────────────────────────
344
+
125
345
  const mode = await select({
126
346
  message: "How do you want to link this project?",
127
347
  options: [
128
- { value: "new", label: "Create new app" },
348
+ { value: "new", label: "Create new app" },
129
349
  { value: "existing", label: "Link to existing app" },
130
350
  ],
131
351
  });
132
-
133
352
  if (isCancel(mode)) cancel("cancelled");
134
353
 
135
354
  let app_id, app_name;
136
355
 
137
356
  if (mode === "new") {
138
- const name = await text({
139
- message: "App name (alphanumeric)",
140
- validate: v => /^[a-zA-Z0-9]+$/.test(v) ? undefined : "Alphanumeric only",
141
- });
142
- if (isCancel(name)) cancel("cancelled");
357
+ const suggested = sanitiseName(basename(cwd) || "myapp");
358
+ let rawName;
359
+
360
+ if (opts.name) {
361
+ rawName = opts.name;
362
+ } else {
363
+ rawName = await text({
364
+ message: `App name [${suggested}]`,
365
+ placeholder: suggested,
366
+ validate: v => {
367
+ const s = sanitiseName(v || suggested);
368
+ return s.length ? undefined : "Alphanumeric only";
369
+ },
370
+ });
371
+ if (isCancel(rawName)) cancel("cancelled");
372
+ }
373
+
374
+ const name = sanitiseName(rawName || suggested);
143
375
 
144
376
  const result = await api("/apps", {
145
377
  method: "POST",
146
378
  body: JSON.stringify({ name }),
147
379
  }).catch(err => cancel(err.message));
148
380
 
149
- app_id = result.app_id;
381
+ app_id = result.app_id;
150
382
  app_name = result.name;
151
383
  } else {
152
384
  const apps = await api("/apps").catch(err => cancel(err.message));
153
-
154
- if (!apps.length) {
155
- console.log(ui.blank());
156
- console.log(ui.errBlock("no apps found", "use 'Create new app' instead"));
157
- process.exit(1);
158
- }
385
+ if (!apps.length) cancel("no apps found", "use 'Create new app' instead");
159
386
 
160
387
  const chosen = await select({
161
388
  message: "Select an app",
@@ -164,59 +391,119 @@ export const initCommand = new Command("init")
164
391
  if (isCancel(chosen)) cancel("cancelled");
165
392
 
166
393
  const found = apps.find(a => a.app_id === chosen);
167
- app_id = found.app_id;
394
+ app_id = found.app_id;
168
395
  app_name = found.name;
169
396
  }
170
397
 
171
- const env_plaintext = readFileSync(".env", "utf-8");
398
+ console.log(ui.blank());
399
+ console.log(ui.kv("APP", app_name));
400
+ console.log(ui.kv("DIR", ui.slate(cwd)));
401
+ console.log(ui.blank());
402
+ console.log(ui.hr());
403
+ console.log(ui.blank());
404
+
405
+ // ── Step tracker ──────────────────────────────────────────────────────────
172
406
 
173
- const s = spinner();
174
- s.start("Pushing v1");
407
+ const steps = {
408
+ upload: { label: "upload .env", status: "pending" },
409
+ gitignore: { label: "gitignore .gitignore", status: "pending" },
410
+ sdk: { label: "sdk @suronai/sdk", status: "pending" },
411
+ patch: { label: "patch entry point", status: "pending" },
412
+ };
175
413
 
176
- const result = await api(`/apps/${app_id}/versions`, {
177
- method: "POST",
178
- body: JSON.stringify({ env_plaintext }),
179
- }).catch(err => { s.stop("failed"); cancel(err.message); });
414
+ const printSteps = () => {
415
+ for (const s of Object.values(steps)) {
416
+ console.log(ui.step(s.status, s.label, s.note));
417
+ }
418
+ };
180
419
 
181
- s.stop("Done");
420
+ // ── Upload ────────────────────────────────────────────────────────────────
182
421
 
183
- const suronJson = { app_name, app_id, version: result.version };
184
- writeFileSync(".suron.json", JSON.stringify(suronJson, null, 2) + "\n");
422
+ steps.upload.status = "run";
423
+ const env_plaintext = readFileSync(join(cwd, ".env"), "utf-8");
185
424
 
186
- console.log(ui.blank());
187
- console.log(ui.step("done", "encrypted and uploaded .env"));
188
- console.log(ui.step("done", `version ${result.version}`));
189
- console.log(ui.step("done", "wrote .suron.json"));
425
+ let uploadResult;
426
+ try {
427
+ uploadResult = await api(`/apps/${app_id}/versions`, {
428
+ method: "POST",
429
+ body: JSON.stringify({ env_plaintext }),
430
+ });
431
+ steps.upload.status = "done";
432
+ steps.upload.note = `v${uploadResult.version}`;
433
+ } catch (err) {
434
+ steps.upload.status = "fail";
435
+ printSteps();
436
+ console.log(ui.blank());
437
+ cancel(err.message);
438
+ }
439
+
440
+ // ── .suron.json ───────────────────────────────────────────────────────────
441
+
442
+ writeFileSync(
443
+ join(cwd, ".suron.json"),
444
+ JSON.stringify({ app_name, app_id, version: uploadResult.version }, null, 2) + "\n",
445
+ "utf-8"
446
+ );
447
+
448
+ // ── .gitignore ────────────────────────────────────────────────────────────
449
+
450
+ steps.gitignore.status = "run";
451
+ const gitResult = ensureGitignore(cwd);
452
+ steps.gitignore.status = "done";
453
+ steps.gitignore.note = gitResult;
454
+
455
+ // ── SDK install ───────────────────────────────────────────────────────────
456
+
457
+ steps.sdk.status = "run";
458
+ const sdkResult = installSdk(cwd);
459
+ steps.sdk.status = sdkResult.status;
460
+ steps.sdk.note = sdkResult.note;
461
+
462
+ // ── Print steps before patch (patch is interactive) ───────────────────────
463
+
464
+ steps.patch.status = "run";
465
+ printSteps();
190
466
  console.log(ui.blank());
191
467
  console.log(ui.hr());
192
468
  console.log(ui.blank());
193
- console.log(ui.infoBlock("add .suron.json to git · add .env to .gitignore"));
469
+
470
+ // ── Entry-point patch ─────────────────────────────────────────────────────
471
+
472
+ await patchEntryPoint(cwd, sdkResult.isEsm ?? false);
473
+ steps.patch.status = "done";
474
+
475
+ // ── Ready ─────────────────────────────────────────────────────────────────
476
+
477
+ console.log(ui.readyBlock([
478
+ { label: ".env", note: "uploaded · keep out of git" },
479
+ { label: ".suron.json", note: "safe to commit" },
480
+ { label: ".gitignore", note: gitResult },
481
+ ]));
194
482
  console.log(ui.blank());
195
483
  });
196
484
 
197
- export const upCommand = new Command("up")
198
- .description("Push a new version of .env")
485
+ export const whoamiCommand = new Command("whoami")
486
+ .description("Show the current logged-in user")
199
487
  .action(async () => {
200
488
  requireToken();
489
+ const user = await api("/auth/me").catch(err => cancel(err.message));
490
+ console.log(ui.blank());
491
+ console.log(ui.kv("EMAIL", user.email));
492
+ console.log(ui.kv("ROLE", user.role));
493
+ console.log(ui.blank());
494
+ });
201
495
 
202
- if (!existsSync(".suron.json")) {
203
- console.log(ui.blank());
204
- console.log(ui.errBlock(".suron.json not found", "run: suron init"));
205
- console.log(ui.blank());
206
- process.exit(1);
207
- }
208
-
209
- if (!existsSync(".env")) {
210
- console.log(ui.blank());
211
- console.log(ui.errBlock(".env not found"));
212
- console.log(ui.blank());
213
- process.exit(1);
214
- }
496
+ export const upCommand = new Command("up")
497
+ .action(async () => {
498
+ requireToken();
499
+ const cwd = process.cwd();
215
500
 
216
- const suronJson = JSON.parse(readFileSync(".suron.json", "utf-8"));
217
- const { app_id, app_name } = suronJson;
501
+ if (!existsSync(join(cwd, ".suron.json"))) cancel(".suron.json not found", "run: suron init");
502
+ if (!existsSync(join(cwd, ".env"))) cancel(".env not found");
218
503
 
219
- const env_plaintext = readFileSync(".env", "utf-8");
504
+ const suronJson = JSON.parse(readFileSync(join(cwd, ".suron.json"), "utf-8"));
505
+ const { app_id } = suronJson;
506
+ const env_plaintext = readFileSync(join(cwd, ".env"), "utf-8");
220
507
 
221
508
  const s = spinner();
222
509
  s.start("Pushing new version");
@@ -229,7 +516,7 @@ export const upCommand = new Command("up")
229
516
  s.stop("Done");
230
517
 
231
518
  suronJson.version = result.version;
232
- writeFileSync(".suron.json", JSON.stringify(suronJson, null, 2) + "\n");
519
+ writeFileSync(join(cwd, ".suron.json"), JSON.stringify(suronJson, null, 2) + "\n");
233
520
 
234
521
  console.log(ui.blank());
235
522
  console.log(ui.step("done", `version ${result.version} pushed`));
package/src/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { Command } from "commander";
4
4
  import { createRequire } from "module";
5
- import { loginCommand, logoutCommand, initCommand, upCommand } from "./commands.js";
5
+ import { loginCommand, logoutCommand, initCommand, upCommand, whoamiCommand } from "./commands.js";
6
6
 
7
7
  const require = createRequire(import.meta.url);
8
8
  const { version } = require("../package.json");
@@ -16,6 +16,7 @@ program
16
16
 
17
17
  program.addCommand(loginCommand);
18
18
  program.addCommand(logoutCommand);
19
+ program.addCommand(whoamiCommand);
19
20
  program.addCommand(initCommand);
20
21
  program.addCommand(upCommand);
21
22
 
package/src/utils/api.js CHANGED
@@ -1,13 +1,15 @@
1
1
  import { getToken } from "./config.js";
2
2
 
3
- const BASE_URL = "https://suron.vercel.app";
3
+ const WEB_URL = process.env.SURON_WEB_URL ?? "https://suron.vercel.app";
4
+ const API_URL = `${WEB_URL}/api`;
5
+ const BASE_URL = WEB_URL;
4
6
 
5
7
  export async function api(path, options = {}) {
6
8
  const token = getToken();
7
9
  const headers = { "Content-Type": "application/json", ...options.headers };
8
10
  if (token) headers["Authorization"] = `Bearer ${token}`;
9
11
 
10
- const res = await fetch(`${BASE_URL}${path}`, { ...options, headers });
12
+ const res = await fetch(`${API_URL}${path}`, { ...options, headers });
11
13
 
12
14
  if (!res.ok) {
13
15
  const data = await res.json().catch(() => ({}));