@vaultic-dev/cli 0.1.0 → 0.1.1
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/README.md +63 -0
- package/dist/index.js +159 -10
- package/package.json +1 -1
package/README.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# @vaultic-dev/cli
|
|
2
|
+
|
|
3
|
+
Command-line client for [Vaultic](https://github.com/vaultic-dev/vaultic) — encrypted,
|
|
4
|
+
versioned secrets management with environment inheritance, rotation, and audit logging. This
|
|
5
|
+
package installs the `vaultic` binary; it talks to a Vaultic server over the REST API and never
|
|
6
|
+
handles crypto itself beyond decrypting values already fetched over the wire.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install -g @vaultic-dev/cli
|
|
12
|
+
vaultic --help
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Requires Node.js >= 20. You'll also need a Vaultic server to talk to — either your own
|
|
16
|
+
self-hosted instance, or one your team runs (see the
|
|
17
|
+
[main repo README](https://github.com/vaultic-dev/vaultic#readme) for self-hosting instructions).
|
|
18
|
+
|
|
19
|
+
## Quick start
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
vaultic login # opens a browser to approve the login (--no-browser for the
|
|
23
|
+
# email/password prompt, --token to paste one)
|
|
24
|
+
vaultic init # interactive: pick/create workspace -> project -> default env
|
|
25
|
+
# writes .vaultic.yaml in the current directory
|
|
26
|
+
|
|
27
|
+
vaultic secrets set FOO bar # stored encrypted server-side, versioned
|
|
28
|
+
vaultic secrets list # masked by default
|
|
29
|
+
vaultic secrets list --reveal # explicit reveal, audited separately from list/read
|
|
30
|
+
|
|
31
|
+
vaultic run -- printenv FOO # injects secrets as env vars into the subprocess -> "bar"
|
|
32
|
+
vaultic export # writes .env (dotenv format) with a checksum header
|
|
33
|
+
vaultic status # compares local .env vs the server
|
|
34
|
+
vaultic sync # pulls latest values, regenerates .env, runs post_sync hook
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
By default, `vaultic --server <url>` (or the `VAULTIC_API_URL` env var) points at your server;
|
|
38
|
+
otherwise it defaults to `http://localhost:4000`.
|
|
39
|
+
|
|
40
|
+
## Command reference
|
|
41
|
+
|
|
42
|
+
| Group | Commands |
|
|
43
|
+
|---|---|
|
|
44
|
+
| Auth | `login`, `logout`, `whoami` |
|
|
45
|
+
| Project setup | `init` (writes `.vaultic.yaml`) |
|
|
46
|
+
| Secrets | `secrets list/get/set/delete/history/rollback/rename/rotate`, `secrets override set/clear`, `rotation-providers` |
|
|
47
|
+
| Local file sync | `run`, `export`, `import`, `status`, `sync` |
|
|
48
|
+
| Environments | `env list/create/duplicate/diff/promote/lock/unlock/proposals/approve/reject` |
|
|
49
|
+
| Workspace | `workspace invite/invites/revoke-invite/members/set-role/remove-member/accept-invite` |
|
|
50
|
+
| Access grants | `access grant/list/revoke` |
|
|
51
|
+
| Service tokens | `tokens create/list/revoke` |
|
|
52
|
+
| Sharing | `share <key>` — one-time/limited-view unauthenticated link |
|
|
53
|
+
| Webhooks | `webhooks create/list/delete/deliveries` |
|
|
54
|
+
| Third-party push | `push github`, `push vercel` |
|
|
55
|
+
| Git integration | `git-integration setup/show/remove` — ephemeral preview environments |
|
|
56
|
+
|
|
57
|
+
Run `vaultic <command> --help` for a command's full options.
|
|
58
|
+
|
|
59
|
+
## Links
|
|
60
|
+
|
|
61
|
+
- [Full documentation, deployment guide, and security model](https://github.com/vaultic-dev/vaultic#readme)
|
|
62
|
+
- [Changelog](https://github.com/vaultic-dev/vaultic/blob/main/CHANGELOG.md)
|
|
63
|
+
- [Issues](https://github.com/vaultic-dev/vaultic/issues)
|
package/dist/index.js
CHANGED
|
@@ -100,6 +100,11 @@ import { z } from "zod";
|
|
|
100
100
|
var SECRET_KEY_REGEX = /^[A-Z][A-Z0-9_]*$/;
|
|
101
101
|
var SlugSchema = z.string().min(1).max(64).regex(/^[a-z0-9][a-z0-9-]*$/, "must be lowercase alphanumeric with dashes");
|
|
102
102
|
var SecretKeySchema = z.string().min(1).max(256).regex(SECRET_KEY_REGEX, "keys must be UPPER_SNAKE_CASE (e.g. DATABASE_URL)");
|
|
103
|
+
function normalizeTag(tag) {
|
|
104
|
+
return tag.trim().toLowerCase();
|
|
105
|
+
}
|
|
106
|
+
var TagSchema = z.string().trim().min(1, "tag cannot be empty").max(64, "tag must be 64 characters or fewer").transform(normalizeTag);
|
|
107
|
+
var SecretTagsSchema = z.array(TagSchema).max(50, "a secret can have at most 50 tags").transform((tags) => Array.from(new Set(tags)));
|
|
103
108
|
var RoleSchema = z.enum(["owner", "admin", "developer", "read-only"]);
|
|
104
109
|
var TokenAccessSchema = z.enum(["read", "write"]);
|
|
105
110
|
var SecretTypeHintSchema = z.enum([
|
|
@@ -227,7 +232,11 @@ var SecretSummarySchema = z.object({
|
|
|
227
232
|
/** when this secret's value was last changed — `rotatedAt` if it's ever gone through
|
|
228
233
|
* `rotate`, otherwise the most recent version's createdAt. Used to compute rotation-due
|
|
229
234
|
* state (see `isRotationDue`) client-side, in the CLI, and by the scheduled lifecycle job. */
|
|
230
|
-
lastRotatedAt: z.string().nullable().optional()
|
|
235
|
+
lastRotatedAt: z.string().nullable().optional(),
|
|
236
|
+
/** freeform, normalized (lowercase + trim) tags assigned to this secret (spec: "Tags on
|
|
237
|
+
* secrets + tag-based search"). Visible to anyone who can see the secret at all — not
|
|
238
|
+
* sensitive, same as key names. */
|
|
239
|
+
tags: z.array(z.string()).optional()
|
|
231
240
|
});
|
|
232
241
|
var SecretRevealedSchema = SecretSummarySchema.extend({
|
|
233
242
|
value: z.string()
|
|
@@ -315,6 +324,38 @@ var AuditLogEntrySchema = z.object({
|
|
|
315
324
|
ip: z.string().nullable(),
|
|
316
325
|
createdAt: z.string()
|
|
317
326
|
});
|
|
327
|
+
var NotificationSchema = z.object({
|
|
328
|
+
id: z.string(),
|
|
329
|
+
type: z.string(),
|
|
330
|
+
refId: z.string().nullable(),
|
|
331
|
+
refType: z.string().nullable(),
|
|
332
|
+
data: z.record(z.string(), z.unknown()).nullable(),
|
|
333
|
+
read: z.boolean(),
|
|
334
|
+
readAt: z.string().nullable(),
|
|
335
|
+
createdAt: z.string()
|
|
336
|
+
});
|
|
337
|
+
var NotificationListResponseSchema = z.object({
|
|
338
|
+
notifications: z.array(NotificationSchema),
|
|
339
|
+
unreadCount: z.number()
|
|
340
|
+
});
|
|
341
|
+
var SecretCommentSchema = z.object({
|
|
342
|
+
id: z.string(),
|
|
343
|
+
secretId: z.string(),
|
|
344
|
+
authorId: z.string().nullable(),
|
|
345
|
+
authorName: z.string().nullable(),
|
|
346
|
+
authorEmail: z.string().nullable(),
|
|
347
|
+
body: z.string(),
|
|
348
|
+
createdAt: z.string(),
|
|
349
|
+
/** null until the comment has been edited at least once */
|
|
350
|
+
editedAt: z.string().nullable()
|
|
351
|
+
});
|
|
352
|
+
var SecretCommentBodySchema = z.string().trim().min(1, "comment cannot be empty").max(4e3, "comment must be 4000 characters or fewer");
|
|
353
|
+
var CreateSecretCommentRequestSchema = z.object({
|
|
354
|
+
body: SecretCommentBodySchema
|
|
355
|
+
});
|
|
356
|
+
var UpdateSecretCommentRequestSchema = z.object({
|
|
357
|
+
body: SecretCommentBodySchema
|
|
358
|
+
});
|
|
318
359
|
var RegisterRequestSchema = z.object({
|
|
319
360
|
email: z.string().email(),
|
|
320
361
|
password: z.string().min(8),
|
|
@@ -377,11 +418,17 @@ var SetSecretRequestSchema = z.object({
|
|
|
377
418
|
expiresAt: z.string().datetime().nullable().optional(),
|
|
378
419
|
/** omit to leave unchanged, null to clear, a positive integer (days) to set (see
|
|
379
420
|
* "Rotation reminders" in the spec) */
|
|
380
|
-
rotationReminderIntervalDays: z.number().int().positive().nullable().optional()
|
|
421
|
+
rotationReminderIntervalDays: z.number().int().positive().nullable().optional(),
|
|
422
|
+
/** omit to leave tags unchanged, an array (possibly empty, to clear all tags) to replace
|
|
423
|
+
* the full tag set — normalized/deduped by SecretTagsSchema (see "Tags on secrets"). */
|
|
424
|
+
tags: SecretTagsSchema.optional()
|
|
381
425
|
});
|
|
382
426
|
var UpdateSecretMetadataRequestSchema = z.object({
|
|
383
427
|
expiresAt: z.string().datetime().nullable().optional(),
|
|
384
|
-
rotationReminderIntervalDays: z.number().int().positive().nullable().optional()
|
|
428
|
+
rotationReminderIntervalDays: z.number().int().positive().nullable().optional(),
|
|
429
|
+
/** omit to leave tags unchanged, an array (possibly empty, to clear all tags) to replace
|
|
430
|
+
* the full tag set. */
|
|
431
|
+
tags: SecretTagsSchema.optional()
|
|
385
432
|
});
|
|
386
433
|
var RenameSecretRequestSchema = z.object({
|
|
387
434
|
newKey: SecretKeySchema
|
|
@@ -558,6 +605,20 @@ var SecretsHealthSchema = z.object({
|
|
|
558
605
|
expiredReminders: z.array(ExpiredReminderSchema),
|
|
559
606
|
inactiveConfigs: z.array(InactiveConfigSchema)
|
|
560
607
|
});
|
|
608
|
+
var DuplicateValueMemberSchema = SecretsHealthLocationSchema.extend({
|
|
609
|
+
key: z.string()
|
|
610
|
+
});
|
|
611
|
+
var DuplicateValueGroupSchema = z.object({
|
|
612
|
+
count: z.number().int(),
|
|
613
|
+
members: z.array(DuplicateValueMemberSchema)
|
|
614
|
+
});
|
|
615
|
+
var DuplicateValuesResponseSchema = z.object({
|
|
616
|
+
groups: z.array(DuplicateValueGroupSchema),
|
|
617
|
+
/** true if at least one visible secret's current version hasn't been HMAC-indexed yet
|
|
618
|
+
* (null valueHmac) — e.g. a version written before this feature existed. Lets the UI show
|
|
619
|
+
* a "results may be incomplete" notice instead of silently under-reporting duplicates. */
|
|
620
|
+
incomplete: z.boolean()
|
|
621
|
+
});
|
|
561
622
|
|
|
562
623
|
// src/lib/project-config.ts
|
|
563
624
|
var CONFIG_FILE = ".vaultic.yaml";
|
|
@@ -724,6 +785,88 @@ function exitCancelled() {
|
|
|
724
785
|
|
|
725
786
|
// src/commands/auth.ts
|
|
726
787
|
import prompts2 from "prompts";
|
|
788
|
+
|
|
789
|
+
// src/lib/browser-login.ts
|
|
790
|
+
import { createServer } from "http";
|
|
791
|
+
import { randomBytes } from "crypto";
|
|
792
|
+
import { spawn } from "child_process";
|
|
793
|
+
function openInBrowser(url) {
|
|
794
|
+
try {
|
|
795
|
+
const platform = process.platform;
|
|
796
|
+
const [cmd, args] = platform === "darwin" ? ["open", [url]] : platform === "win32" ? ["cmd", ["/c", "start", '""', url]] : ["xdg-open", [url]];
|
|
797
|
+
spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
|
|
798
|
+
} catch {
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
var DEFAULT_TIMEOUT_MS = 5 * 60 * 1e3;
|
|
802
|
+
async function browserLogin(opts) {
|
|
803
|
+
const { serverUrl, timeoutMs = DEFAULT_TIMEOUT_MS, onUrl } = opts;
|
|
804
|
+
const providers = await fetch(`${serverUrl}/auth/providers`).then((res) => res.ok ? res.json() : null).catch(() => null);
|
|
805
|
+
const webAppUrl = providers?.webAppUrl;
|
|
806
|
+
if (!webAppUrl) {
|
|
807
|
+
throw new Error(
|
|
808
|
+
"This server doesn't support browser login (no webAppUrl from /auth/providers) -- use --email/--password or --token instead."
|
|
809
|
+
);
|
|
810
|
+
}
|
|
811
|
+
const state = randomBytes(24).toString("hex");
|
|
812
|
+
return new Promise((resolve, reject) => {
|
|
813
|
+
let settled = false;
|
|
814
|
+
const timeout = setTimeout(() => {
|
|
815
|
+
if (settled) return;
|
|
816
|
+
settled = true;
|
|
817
|
+
server.close();
|
|
818
|
+
reject(new Error("Timed out waiting for browser login approval."));
|
|
819
|
+
}, timeoutMs);
|
|
820
|
+
const server = createServer((req, res) => {
|
|
821
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
822
|
+
if (url.pathname !== "/callback") {
|
|
823
|
+
res.writeHead(404).end();
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
const returnedState = url.searchParams.get("state");
|
|
827
|
+
const code = url.searchParams.get("code");
|
|
828
|
+
const finish = (err, result) => {
|
|
829
|
+
if (settled) return;
|
|
830
|
+
settled = true;
|
|
831
|
+
clearTimeout(timeout);
|
|
832
|
+
server.close();
|
|
833
|
+
if (err) reject(err);
|
|
834
|
+
else resolve(result);
|
|
835
|
+
};
|
|
836
|
+
if (returnedState !== state || !code) {
|
|
837
|
+
res.writeHead(400, { "content-type": "text/html" }).end("<h1>Invalid login attempt.</h1>You can close this tab.");
|
|
838
|
+
finish(new Error("Received an invalid or mismatched login callback."));
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
fetch(`${serverUrl}/auth/cli/exchange`, {
|
|
842
|
+
method: "POST",
|
|
843
|
+
headers: { "content-type": "application/json" },
|
|
844
|
+
body: JSON.stringify({ code })
|
|
845
|
+
}).then(async (exchangeRes) => {
|
|
846
|
+
const data = await exchangeRes.json();
|
|
847
|
+
if (!exchangeRes.ok) throw new Error(data?.error ?? "Login failed");
|
|
848
|
+
res.writeHead(200, { "content-type": "text/html" }).end("<h1>Logged in!</h1>You can close this tab and return to your terminal.");
|
|
849
|
+
finish(null, data);
|
|
850
|
+
}).catch((err) => {
|
|
851
|
+
res.writeHead(500, { "content-type": "text/html" }).end("<h1>Login failed.</h1>You can close this tab.");
|
|
852
|
+
finish(err instanceof Error ? err : new Error(String(err)));
|
|
853
|
+
});
|
|
854
|
+
});
|
|
855
|
+
server.listen(0, "127.0.0.1", () => {
|
|
856
|
+
const port = server.address().port;
|
|
857
|
+
const loginUrl = `${webAppUrl}/cli-login?state=${state}&port=${port}`;
|
|
858
|
+
if (onUrl) {
|
|
859
|
+
onUrl(loginUrl);
|
|
860
|
+
} else {
|
|
861
|
+
console.log(`Opening ${loginUrl} in your browser\u2026`);
|
|
862
|
+
console.log("Waiting for you to approve the login (Ctrl+C to cancel)\u2026");
|
|
863
|
+
openInBrowser(loginUrl);
|
|
864
|
+
}
|
|
865
|
+
});
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// src/commands/auth.ts
|
|
727
870
|
async function loginCommand(opts) {
|
|
728
871
|
const serverUrl = opts.server ?? process.env.VAULTIC_API_URL ?? DEFAULT_SERVER_URL;
|
|
729
872
|
if (opts.token) {
|
|
@@ -731,6 +874,12 @@ async function loginCommand(opts) {
|
|
|
731
874
|
console.log(`Saved credentials for ${serverUrl}`);
|
|
732
875
|
return;
|
|
733
876
|
}
|
|
877
|
+
if (!opts.email && !opts.password && opts.browser !== false) {
|
|
878
|
+
const res = await browserLogin({ serverUrl });
|
|
879
|
+
saveCredentials({ serverUrl, token: res.token, email: res.user.email });
|
|
880
|
+
console.log(`Logged in as ${res.user.email}`);
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
734
883
|
const email = opts.email ?? (await prompts2({ type: "text", name: "email", message: "Email" })).email;
|
|
735
884
|
const password = opts.password ?? (await prompts2({ type: "password", name: "password", message: "Password" })).password;
|
|
736
885
|
if (!email || !password) {
|
|
@@ -766,7 +915,7 @@ async function whoamiCommand() {
|
|
|
766
915
|
console.log("Not logged in.");
|
|
767
916
|
return;
|
|
768
917
|
}
|
|
769
|
-
if (creds.token.startsWith("
|
|
918
|
+
if (creds.token.startsWith("sht_")) {
|
|
770
919
|
console.log(`Authenticated with a service token against ${creds.serverUrl}`);
|
|
771
920
|
return;
|
|
772
921
|
}
|
|
@@ -776,7 +925,7 @@ async function whoamiCommand() {
|
|
|
776
925
|
}
|
|
777
926
|
|
|
778
927
|
// src/commands/run.ts
|
|
779
|
-
import { spawn } from "child_process";
|
|
928
|
+
import { spawn as spawn2 } from "child_process";
|
|
780
929
|
|
|
781
930
|
// src/lib/context.ts
|
|
782
931
|
function envBase(cfg, env2) {
|
|
@@ -802,7 +951,7 @@ async function runCommand(commandParts, opts) {
|
|
|
802
951
|
const fetchSecrets = () => api.get(`${envBase(cfg, env2)}/export`);
|
|
803
952
|
if (!opts.watch) {
|
|
804
953
|
const secrets2 = await fetchSecrets();
|
|
805
|
-
const child2 =
|
|
954
|
+
const child2 = spawn2(cmd, args, { stdio: "inherit", env: { ...process.env, ...secrets2 } });
|
|
806
955
|
child2.on("exit", (code, signal) => {
|
|
807
956
|
if (signal) process.kill(process.pid, signal);
|
|
808
957
|
else process.exit(code ?? 0);
|
|
@@ -819,7 +968,7 @@ async function runCommand(commandParts, opts) {
|
|
|
819
968
|
let stopped = false;
|
|
820
969
|
let restarting = false;
|
|
821
970
|
function spawnChild() {
|
|
822
|
-
child =
|
|
971
|
+
child = spawn2(cmd, args, { stdio: "inherit", env: { ...process.env, ...current } });
|
|
823
972
|
child.on("exit", (code, signal) => {
|
|
824
973
|
if (stopped || restarting) return;
|
|
825
974
|
stopped = true;
|
|
@@ -1094,11 +1243,11 @@ async function syncCommand(opts) {
|
|
|
1094
1243
|
import readline from "readline";
|
|
1095
1244
|
|
|
1096
1245
|
// src/rotation/postgres.ts
|
|
1097
|
-
import { randomBytes } from "crypto";
|
|
1246
|
+
import { randomBytes as randomBytes2 } from "crypto";
|
|
1098
1247
|
import { Client } from "pg";
|
|
1099
1248
|
var ROLE_NAME_REGEX = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
1100
1249
|
function generatePassword(bytes = 24) {
|
|
1101
|
-
return
|
|
1250
|
+
return randomBytes2(bytes).toString("base64url");
|
|
1102
1251
|
}
|
|
1103
1252
|
function sqlLiteral(value) {
|
|
1104
1253
|
return `'${value.replace(/'/g, "''")}'`;
|
|
@@ -1956,7 +2105,7 @@ async function gitIntegrationRemoveCommand() {
|
|
|
1956
2105
|
var program = new Command();
|
|
1957
2106
|
program.name("vaultic").description("Vaultic CLI").version("0.1.0");
|
|
1958
2107
|
program.command("init").description("Initialize this directory as a Vaultic project (writes .vaultic.yaml)").action(wrap(initCommand));
|
|
1959
|
-
program.command("login").description("Log in with email/password, or
|
|
2108
|
+
program.command("login").description("Log in via your browser (default), with email/password, or by pasting a token with --token").option("--token <token>", "paste a session or service token instead of logging in interactively").option("--server <url>", "Vaultic server URL").option("--email <email>", "email (non-interactive; skips the browser flow)").option("--password <password>", "password (non-interactive; skips the browser flow)").option("--no-browser", "skip opening a browser; prompt for email/password instead").action(wrap(loginCommand));
|
|
1960
2109
|
program.command("logout").description("Clear stored credentials").action(wrap(logoutCommand));
|
|
1961
2110
|
program.command("whoami").description("Show the current logged-in user").action(wrap(whoamiCommand));
|
|
1962
2111
|
program.command("run").description("Inject secrets as env vars and run a command").option("-e, --env <environment>", "environment to use").option("--watch", "poll for secret changes and restart the subprocess when they change").option("--poll-interval <ms>", "watch poll interval in milliseconds (default 2000)").allowUnknownOption().argument("<command...>", "command to run, after --").action(wrap((commandParts, opts) => runCommand(commandParts, opts)));
|