@suronai/cli 0.1.40 → 0.1.42

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