sanctum-cli 0.1.0
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/DEMO.md +172 -0
- package/README.md +110 -0
- package/dist/cli.js +735 -0
- package/dist/config.js +156 -0
- package/dist/prompt.js +98 -0
- package/package.json +38 -0
package/DEMO.md
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# Sanctum CLI — Demo
|
|
2
|
+
|
|
3
|
+
A complete walkthrough of the developer workflow: from a bare machine to an app
|
|
4
|
+
running with injected secrets. No `.env` files involved.
|
|
5
|
+
|
|
6
|
+
## 0. Prerequisites
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm install -g @sanctum/cli
|
|
10
|
+
sanctum --help
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## 1. Log in (once per machine)
|
|
14
|
+
|
|
15
|
+
Machine identity (recommended for CI and dev machines):
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
sanctum login \
|
|
19
|
+
--client-id d8421d28-3f27-4f44-944d-476ba61063da \
|
|
20
|
+
--client-secret <your-secret> \
|
|
21
|
+
--base-url https://sanctum.example.com
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
Logged in with Universal Auth (https://sanctum.example.com) as profile "default".
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Or a user token:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
sanctum login --token <token> --base-url https://sanctum.example.com
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Credentials land in `~/.sanctum/credentials.json` (mode 600). Nothing is
|
|
35
|
+
written to your project yet.
|
|
36
|
+
|
|
37
|
+
Need a second identity on the same machine? Add a named profile:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
sanctum login --client-id <ci-bot-id> --client-secret <ci-bot-secret> --profile ci-bot
|
|
41
|
+
sanctum profiles
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
* default https://sanctum.example.com
|
|
46
|
+
ci-bot https://sanctum.example.com
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## 2. Discover what you can access
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
sanctum projects list
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
rim-api-mf6-x RimAPI ef120ef1-8ae8-4c6b-8547-eb010073fb4f
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## 3. Link a folder to a project (once per repo/app)
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
cd ~/work/rim-api
|
|
63
|
+
sanctum init --project rim-api-mf6-x --env dev --path /
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
Wrote /home/you/work/rim-api/sanctum-config.json
|
|
68
|
+
project: rim-api-mf6-x
|
|
69
|
+
environment: dev
|
|
70
|
+
secretPath: /
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`sanctum-config.json`:
|
|
74
|
+
|
|
75
|
+
```json
|
|
76
|
+
{
|
|
77
|
+
"projectSlug": "rim-api-mf6-x",
|
|
78
|
+
"environment": "dev",
|
|
79
|
+
"secretPath": "/"
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**Commit this file.** It contains no secrets — just routing. Every developer
|
|
84
|
+
or CI agent that clones the repo gets the same wiring and supplies their own
|
|
85
|
+
credentials.
|
|
86
|
+
|
|
87
|
+
## 4. Day-to-day usage
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
sanctum envs # dev, staging, prod
|
|
91
|
+
sanctum secrets list # keys only, never values
|
|
92
|
+
sanctum secrets set DB_URL=postgres://localhost:5432/app
|
|
93
|
+
sanctum secrets get DB_URL # prints the value
|
|
94
|
+
sanctum secrets rm DB_URL
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Run your app with secrets injected
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
sanctum run -- npm run dev
|
|
101
|
+
sanctum run -- node server.js
|
|
102
|
+
sanctum run --env staging -- ./deploy.sh
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Your app reads `process.env.DB_URL` like normal. Secrets exist only in the
|
|
106
|
+
child process environment — nothing touches disk.
|
|
107
|
+
|
|
108
|
+
### Export when you actually need a file
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
sanctum export # dotenv to stdout
|
|
112
|
+
sanctum export --format json
|
|
113
|
+
sanctum export --out .env.local # opt-in, not the default flow
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Push an existing .env up
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
sanctum push .env
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
Pushed 14 secrets to /
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## 5. Monorepo layout
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
my-mono/
|
|
130
|
+
├── sanctum-config.json # { projectSlug: "my-mono", secretPath: "/shared" }
|
|
131
|
+
├── apps/
|
|
132
|
+
│ ├── api/
|
|
133
|
+
│ │ └── sanctum-config.json # { secretPath: "/apps/api", imports: [] }
|
|
134
|
+
│ └── web/
|
|
135
|
+
│ └── sanctum-config.json # { secretPath: "/apps/web" }
|
|
136
|
+
└── .env.example
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Commands walk up from `cwd` and merge root → leaf. Inside `apps/api`, the CLI
|
|
140
|
+
fetches `/shared` first, then `/apps/api` — the deeper path wins on key
|
|
141
|
+
conflicts. Project/env inherit from the nearest config.
|
|
142
|
+
|
|
143
|
+
## 6. CI / machines
|
|
144
|
+
|
|
145
|
+
No credentials file needed — use env vars:
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
export SANCTUM_CLIENT_ID=...
|
|
149
|
+
export SANCTUM_CLIENT_SECRET=...
|
|
150
|
+
export SANCTUM_BASE_URL=https://sanctum.example.com
|
|
151
|
+
# or a raw token: export SANCTUM_TOKEN=...
|
|
152
|
+
|
|
153
|
+
sanctum run -- npm start
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Or target a stored profile:
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
sanctum secrets list --profile ci-bot
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Profile resolution order: `--profile` flag > `SANCTUM_PROFILE` env >
|
|
163
|
+
`"profile"` in `sanctum-config.json` > `"default"`.
|
|
164
|
+
|
|
165
|
+
## 7. Troubleshooting
|
|
166
|
+
|
|
167
|
+
| Symptom | Fix |
|
|
168
|
+
| --- | --- |
|
|
169
|
+
| `error: Not logged in...` | `sanctum login` or set `SANCTUM_TOKEN` / `SANCTUM_CLIENT_*` |
|
|
170
|
+
| `Folder with path '/x' was not found` | Create the folder in the UI, or `--path /` |
|
|
171
|
+
| Wrong project | `cd` into the folder with the right `sanctum-config.json` |
|
|
172
|
+
| Check who you're acting as | `sanctum whoami` |
|
package/README.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# @sanctum/cli
|
|
2
|
+
|
|
3
|
+
Command-line client for the Sanctum secrets platform.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
cd packages/sdk && npm install && npm run build
|
|
9
|
+
cd ../cli && npm install && npm run build
|
|
10
|
+
npm link # puts `sanctum` on your PATH
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# 1. log in — interactive wizard, or pass credentials
|
|
17
|
+
sanctum login
|
|
18
|
+
sanctum login --client-id <id> --client-secret <secret> [--profile name]
|
|
19
|
+
|
|
20
|
+
# 2. link the current directory — pick a project or create one
|
|
21
|
+
cd my-app
|
|
22
|
+
sanctum init
|
|
23
|
+
|
|
24
|
+
# 3. use it
|
|
25
|
+
sanctum secrets list
|
|
26
|
+
sanctum secrets set API_KEY=abc123
|
|
27
|
+
sanctum run -- npm run dev
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Commands
|
|
31
|
+
|
|
32
|
+
| Command | Description |
|
|
33
|
+
| --- | --- |
|
|
34
|
+
| `sanctum login` | 4-step wizard (URL → method → credentials → profile) or `--token` / `--client-id` + `--client-secret` flags |
|
|
35
|
+
| `sanctum profiles` | List saved credential profiles |
|
|
36
|
+
| `sanctum whoami` | Show profile, identity, org, and token validity |
|
|
37
|
+
| `sanctum --version` | Print CLI version |
|
|
38
|
+
| `sanctum init [--project x] [--env x] [--path x] [--imports a,b] [--profile x] [--force]` | Write `sanctum-config.json`; interactive picker when flags omitted; refuses to overwrite without `--force` |
|
|
39
|
+
| `sanctum status` | Show resolved config + linked monorepo projects |
|
|
40
|
+
| `sanctum agents` | Append a Sanctum usage section to `./AGENTS.md` |
|
|
41
|
+
| `sanctum projects list` | List accessible projects |
|
|
42
|
+
| `sanctum envs` | List environments of the configured project |
|
|
43
|
+
| `sanctum secrets list\|get\|set\|rm` | Read/write secrets (`--env`, `--path`, `--profile`); reports queued-for-approval writes |
|
|
44
|
+
| `sanctum export [--env x] [--format dotenv\|json] [--out file]` | Dump merged secrets |
|
|
45
|
+
| `sanctum diff [file] [--env x] [--values] [--all]` | Masked local-vs-remote comparison; monorepo picker at repo root |
|
|
46
|
+
| `sanctum pull [file] [--env x] [--all] [--yes]` | Merge remote secrets into a local file (updates in place, appends new, keeps local-only) |
|
|
47
|
+
| `sanctum push [file] [--env x] [--dry-run] [--yes]` | Preview-then-push `.env` keys into your `secretPath` |
|
|
48
|
+
| `sanctum run [--env x] -- <cmd>` | Run a command with secrets injected as env vars |
|
|
49
|
+
|
|
50
|
+
## Config discovery
|
|
51
|
+
|
|
52
|
+
Commands find the nearest `sanctum-config.json` walking up from the current
|
|
53
|
+
directory. Ancestor configs merge in root → leaf order, so a monorepo can keep
|
|
54
|
+
shared secrets at the root and per-app folders in subdirectories:
|
|
55
|
+
|
|
56
|
+
```json
|
|
57
|
+
{
|
|
58
|
+
"projectSlug": "my-mono",
|
|
59
|
+
"environment": "dev",
|
|
60
|
+
"secretPath": "/apps/api",
|
|
61
|
+
"imports": ["/shared"],
|
|
62
|
+
"profile": "deploy-bot"
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Merge precedence for keys: root paths first, leaf paths last (deepest wins).
|
|
67
|
+
|
|
68
|
+
### Monorepo tracking
|
|
69
|
+
|
|
70
|
+
Run `sanctum init` in a subdirectory of a linked repo and the outermost config
|
|
71
|
+
gains a `projects` index:
|
|
72
|
+
|
|
73
|
+
```json
|
|
74
|
+
{
|
|
75
|
+
"projectSlug": "my-mono",
|
|
76
|
+
"secretPath": "/shared",
|
|
77
|
+
"projects": {
|
|
78
|
+
"apps/api": "apps/api/sanctum-config.json",
|
|
79
|
+
"apps/web": "apps/web/sanctum-config.json"
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`sanctum status` at the root lists every linked project; `sanctum diff` and
|
|
85
|
+
`sanctum pull` offer a picker (`All` / per-child) or take `--all`.
|
|
86
|
+
|
|
87
|
+
## CI / non-interactive
|
|
88
|
+
|
|
89
|
+
All wizards skip prompts when stdin is not a TTY. Pass flags instead, and use
|
|
90
|
+
`--yes`/`--force` for confirmation gates (`push`, `pull` into an existing file,
|
|
91
|
+
`init` overwrite).
|
|
92
|
+
|
|
93
|
+
## Credentials
|
|
94
|
+
|
|
95
|
+
Secrets never go in `sanctum-config.json` — it is safe to commit. Credentials
|
|
96
|
+
live in `~/.sanctum/credentials.json` (mode 600), keyed by profile:
|
|
97
|
+
|
|
98
|
+
```json
|
|
99
|
+
{
|
|
100
|
+
"default": "default",
|
|
101
|
+
"profiles": {
|
|
102
|
+
"default": { "baseUrl": "http://localhost:4000", "accessToken": "..." },
|
|
103
|
+
"deploy-bot": { "baseUrl": "...", "clientId": "...", "clientSecret": "..." }
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Resolution order: `--profile` flag > `SANCTUM_PROFILE` env > `sanctum-config.json` `"profile"` > `"default"`.
|
|
109
|
+
|
|
110
|
+
Env-var auth (CI): `SANCTUM_TOKEN`, or `SANCTUM_CLIENT_ID` + `SANCTUM_CLIENT_SECRET` (+ `SANCTUM_BASE_URL`).
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,735 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import { basename, dirname, join } from "node:path";
|
|
6
|
+
import { SanctumApiError, SanctumSdk } from "sanctum-sdk";
|
|
7
|
+
import { listProfiles, loadCredentials, registerChildConfig, resolveConfig, saveCredentials, writeProjectConfig } from "./config.js";
|
|
8
|
+
import { input, password, select } from "./prompt.js";
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
const VERSION = require("../package.json").version;
|
|
11
|
+
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
12
|
+
const c = (code, s) => (useColor ? `[${code}m${s}[0m` : String(s));
|
|
13
|
+
const bold = (s) => c("1", s);
|
|
14
|
+
const dim = (s) => c("2", s);
|
|
15
|
+
const green = (s) => c("32", s);
|
|
16
|
+
const yellow = (s) => c("33", s);
|
|
17
|
+
const red = (s) => c("31", s);
|
|
18
|
+
const cyan = (s) => c("36", s);
|
|
19
|
+
const die = (message, code = 1) => {
|
|
20
|
+
console.error(`${red("✗")} ${message}`);
|
|
21
|
+
process.exit(code);
|
|
22
|
+
};
|
|
23
|
+
const ok = (message) => console.log(`${green("✓")} ${message}`);
|
|
24
|
+
/** Decode a JWT payload without verification — display only. */
|
|
25
|
+
const decodeJwtPayload = (token) => {
|
|
26
|
+
try {
|
|
27
|
+
const part = token.split(".")[1];
|
|
28
|
+
return JSON.parse(Buffer.from(part, "base64url").toString("utf8"));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
const takeFlag = (args, name) => {
|
|
35
|
+
const idx = args.indexOf(name);
|
|
36
|
+
if (idx === -1)
|
|
37
|
+
return undefined;
|
|
38
|
+
const value = args[idx + 1];
|
|
39
|
+
args.splice(idx, 2);
|
|
40
|
+
return value;
|
|
41
|
+
};
|
|
42
|
+
const hasFlag = (args, name) => {
|
|
43
|
+
const idx = args.indexOf(name);
|
|
44
|
+
if (idx === -1)
|
|
45
|
+
return false;
|
|
46
|
+
args.splice(idx, 1);
|
|
47
|
+
return true;
|
|
48
|
+
};
|
|
49
|
+
const getClient = async (profile) => {
|
|
50
|
+
const credentials = loadCredentials(profile);
|
|
51
|
+
if (!credentials) {
|
|
52
|
+
return die("Not logged in. Run `sanctum login`, or set SANCTUM_TOKEN / SANCTUM_CLIENT_ID + SANCTUM_CLIENT_SECRET.");
|
|
53
|
+
}
|
|
54
|
+
const sdk = new SanctumSdk({
|
|
55
|
+
baseUrl: credentials.baseUrl,
|
|
56
|
+
accessToken: credentials.accessToken,
|
|
57
|
+
clientId: credentials.clientId,
|
|
58
|
+
clientSecret: credentials.clientSecret
|
|
59
|
+
});
|
|
60
|
+
if (!sdk.accessToken) {
|
|
61
|
+
await sdk.authenticate();
|
|
62
|
+
}
|
|
63
|
+
return sdk;
|
|
64
|
+
};
|
|
65
|
+
const scopeQuery = (config, secretPath, env) => ({
|
|
66
|
+
workspaceId: config.projectId,
|
|
67
|
+
workspaceSlug: config.projectSlug,
|
|
68
|
+
projectSlug: config.projectSlug,
|
|
69
|
+
environment: env ?? config.environment ?? "dev",
|
|
70
|
+
secretPath
|
|
71
|
+
});
|
|
72
|
+
/** Fetch and merge secrets across all resolved paths; last path wins on conflicts. */
|
|
73
|
+
const fetchMergedSecrets = async (sdk, config, env) => {
|
|
74
|
+
const merged = {};
|
|
75
|
+
for (const path of config.paths) {
|
|
76
|
+
const { secrets, imports } = await sdk.secrets.list({
|
|
77
|
+
...scopeQuery(config, path, env),
|
|
78
|
+
viewSecretValue: true,
|
|
79
|
+
expandSecretReferences: true,
|
|
80
|
+
include_imports: true
|
|
81
|
+
});
|
|
82
|
+
for (const imp of imports ?? []) {
|
|
83
|
+
for (const s of imp.secrets)
|
|
84
|
+
merged[s.secretKey] = s.secretValue ?? "";
|
|
85
|
+
}
|
|
86
|
+
for (const s of secrets)
|
|
87
|
+
merged[s.secretKey] = s.secretValue ?? "";
|
|
88
|
+
}
|
|
89
|
+
return merged;
|
|
90
|
+
};
|
|
91
|
+
// ---------- commands ----------
|
|
92
|
+
const cmdLogin = async (args) => {
|
|
93
|
+
let baseUrl = takeFlag(args, "--base-url");
|
|
94
|
+
let token = takeFlag(args, "--token");
|
|
95
|
+
let clientId = takeFlag(args, "--client-id");
|
|
96
|
+
let clientSecret = takeFlag(args, "--client-secret");
|
|
97
|
+
let profile = takeFlag(args, "--profile");
|
|
98
|
+
if (!token && !(clientId && clientSecret)) {
|
|
99
|
+
if (!process.stdin.isTTY) {
|
|
100
|
+
die("Provide --token, or --client-id + --client-secret.");
|
|
101
|
+
}
|
|
102
|
+
const step = (n, total, label) => console.log(`\n[${n}/${total}] ${label}`);
|
|
103
|
+
step(1, 4, "Sanctum instance");
|
|
104
|
+
baseUrl = baseUrl ?? (await input("URL", "http://localhost:4000"));
|
|
105
|
+
step(2, 4, "Login method");
|
|
106
|
+
const method = await select("How do you want to log in?", [
|
|
107
|
+
{ label: "Machine identity", value: "ua", hint: "clientId + clientSecret — recommended for dev/CI" },
|
|
108
|
+
{ label: "Access token", value: "token", hint: "paste a user or identity token" }
|
|
109
|
+
]);
|
|
110
|
+
step(3, 4, "Credentials");
|
|
111
|
+
if (method === "ua") {
|
|
112
|
+
clientId = await input("Client ID");
|
|
113
|
+
clientSecret = await password("Client secret");
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
token = await password("Access token");
|
|
117
|
+
}
|
|
118
|
+
if (!token && !(clientId && clientSecret))
|
|
119
|
+
die("Login aborted.");
|
|
120
|
+
step(4, 4, "Profile name");
|
|
121
|
+
profile = profile ?? (await input("Profile", "default"));
|
|
122
|
+
}
|
|
123
|
+
baseUrl = baseUrl ?? "http://localhost:4000";
|
|
124
|
+
profile = profile ?? "default";
|
|
125
|
+
const credentials = { baseUrl, accessToken: token, clientId, clientSecret };
|
|
126
|
+
if (clientId && clientSecret) {
|
|
127
|
+
const sdk = new SanctumSdk({ baseUrl, clientId, clientSecret });
|
|
128
|
+
const tokens = await sdk.authenticate();
|
|
129
|
+
saveCredentials({ ...credentials, accessToken: tokens.accessToken }, profile);
|
|
130
|
+
console.log(`Logged in with Universal Auth (${baseUrl}) as profile "${profile}".`);
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
saveCredentials(credentials, profile);
|
|
134
|
+
console.log(`Token saved (${baseUrl}) as profile "${profile}".`);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
/** Normalize a name for fuzzy matching: lowercase, strip non-alphanumerics. */
|
|
138
|
+
const normalize = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
139
|
+
/** Best-effort local project name: package.json "name" > git remote repo name > directory name. */
|
|
140
|
+
const detectLocalName = () => {
|
|
141
|
+
try {
|
|
142
|
+
const pkg = join(process.cwd(), "package.json");
|
|
143
|
+
if (existsSync(pkg)) {
|
|
144
|
+
const name = JSON.parse(readFileSync(pkg, "utf8")).name;
|
|
145
|
+
if (name)
|
|
146
|
+
return name.replace(/^@[^/]+\//, "");
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
catch { /* fall through */ }
|
|
150
|
+
try {
|
|
151
|
+
const gitConfig = join(process.cwd(), ".git", "config");
|
|
152
|
+
if (existsSync(gitConfig)) {
|
|
153
|
+
const m = readFileSync(gitConfig, "utf8").match(/url\s*=\s*.+\/([^/\s]+?)(?:\.git)?\s*$/m);
|
|
154
|
+
if (m?.[1])
|
|
155
|
+
return m[1];
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
catch { /* fall through */ }
|
|
159
|
+
return basename(process.cwd());
|
|
160
|
+
};
|
|
161
|
+
const cmdInit = async (args) => {
|
|
162
|
+
let project = takeFlag(args, "--project");
|
|
163
|
+
let environment = takeFlag(args, "--env");
|
|
164
|
+
let secretPath = takeFlag(args, "--path");
|
|
165
|
+
const profile = takeFlag(args, "--profile");
|
|
166
|
+
const imports = (takeFlag(args, "--imports") ?? "")
|
|
167
|
+
.split(",")
|
|
168
|
+
.map((s) => s.trim())
|
|
169
|
+
.filter(Boolean);
|
|
170
|
+
// non-interactive path: --project is enough to write the config
|
|
171
|
+
if (project && !environment)
|
|
172
|
+
environment = "dev";
|
|
173
|
+
if (project && !secretPath)
|
|
174
|
+
secretPath = "/";
|
|
175
|
+
const interactive = process.stdin.isTTY && (!project || !environment || !secretPath);
|
|
176
|
+
if (interactive) {
|
|
177
|
+
const sdk = await getClient(profile);
|
|
178
|
+
if (!project) {
|
|
179
|
+
const { projects } = await sdk.projects.list();
|
|
180
|
+
const mode = await select("Project", [
|
|
181
|
+
{ label: "Link to an existing project", value: "link" },
|
|
182
|
+
{ label: "Create a new project", value: "new" }
|
|
183
|
+
]);
|
|
184
|
+
if (mode === "new") {
|
|
185
|
+
const name = await input("New project name", detectLocalName() ?? undefined);
|
|
186
|
+
const { project: created } = await sdk.projects.create({ projectName: name });
|
|
187
|
+
console.log(`Created project '${created.name}' (${created.slug})`);
|
|
188
|
+
project = created.slug;
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
if (!projects.length)
|
|
192
|
+
return die("No projects accessible with this credential.");
|
|
193
|
+
const localName = normalize(detectLocalName() ?? "");
|
|
194
|
+
const choices = projects.map((p) => ({
|
|
195
|
+
label: `${p.name} (${p.slug})`,
|
|
196
|
+
value: p.slug,
|
|
197
|
+
hint: localName && (normalize(p.slug).includes(localName) || normalize(p.name).includes(localName) || localName.includes(normalize(p.slug)))
|
|
198
|
+
? "detected"
|
|
199
|
+
: undefined
|
|
200
|
+
}));
|
|
201
|
+
choices.sort((a, b) => Number(Boolean(b.hint)) - Number(Boolean(a.hint)));
|
|
202
|
+
project = await select("Select a project", choices);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (!environment) {
|
|
206
|
+
const projectId = project.includes("-") && project.length > 20
|
|
207
|
+
? project
|
|
208
|
+
: (await sdk.projects.getBySlug(project)).id;
|
|
209
|
+
const { environments } = await sdk.projects.listEnvironments(projectId);
|
|
210
|
+
environment = environments.length
|
|
211
|
+
? await select("Environment", environments.map((e) => ({ label: `${e.name} (${e.slug})`, value: e.slug })))
|
|
212
|
+
: await input("Environment", "dev");
|
|
213
|
+
}
|
|
214
|
+
if (!secretPath)
|
|
215
|
+
secretPath = await input("Secret path", "/");
|
|
216
|
+
}
|
|
217
|
+
if (!project || !environment || !secretPath) {
|
|
218
|
+
return die("Usage: sanctum init --project <id-or-slug> [--env dev] [--path /apps/api] [--imports /shared] [--profile name]");
|
|
219
|
+
}
|
|
220
|
+
const config = {
|
|
221
|
+
[project.includes("-") && project.length > 20 ? "projectId" : "projectSlug"]: project,
|
|
222
|
+
environment,
|
|
223
|
+
secretPath,
|
|
224
|
+
...(imports.length ? { imports } : {}),
|
|
225
|
+
...(profile ? { profile } : {})
|
|
226
|
+
};
|
|
227
|
+
const configFile = join(process.cwd(), "sanctum-config.json");
|
|
228
|
+
const force = hasFlag(args, "--force");
|
|
229
|
+
if (existsSync(configFile) && !force) {
|
|
230
|
+
if (!process.stdin.isTTY) {
|
|
231
|
+
die("sanctum-config.json already exists. Re-run with --force to overwrite.");
|
|
232
|
+
}
|
|
233
|
+
const existing = JSON.parse(readFileSync(configFile, "utf8"));
|
|
234
|
+
console.log(`${yellow("!")} existing config: ${existing.projectSlug ?? existing.projectId} @ ${existing.environment ?? "dev"} ${existing.secretPath ?? "/"}`);
|
|
235
|
+
const overwrite = await select("Overwrite it?", [
|
|
236
|
+
{ label: "Yes", value: true },
|
|
237
|
+
{ label: "No", value: false }
|
|
238
|
+
]);
|
|
239
|
+
if (!overwrite) {
|
|
240
|
+
console.log("Keeping existing config.");
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
const file = writeProjectConfig(process.cwd(), config);
|
|
245
|
+
const rootFile = registerChildConfig(process.cwd());
|
|
246
|
+
console.log(`Wrote ${file}`);
|
|
247
|
+
if (rootFile)
|
|
248
|
+
console.log(`Registered in ${rootFile}`);
|
|
249
|
+
console.log(` project: ${project}`);
|
|
250
|
+
console.log(` environment: ${environment}`);
|
|
251
|
+
console.log(` secretPath: ${secretPath}${imports.length ? ` (+ imports: ${imports.join(", ")})` : ""}${profile ? `, profile: ${profile}` : ""}`);
|
|
252
|
+
};
|
|
253
|
+
const cmdStatus = () => {
|
|
254
|
+
const config = resolveConfig();
|
|
255
|
+
console.log(`${cyan("●")} ${bold(config.projectSlug ?? config.projectId ?? "?")} ${dim(`@ ${config.environment ?? "dev"}`)}\n`);
|
|
256
|
+
console.log(` ${dim("config")} ${config.configFile}`);
|
|
257
|
+
console.log(` ${dim("paths")} ${config.paths.join(", ")}`);
|
|
258
|
+
if (config.profile)
|
|
259
|
+
console.log(` ${dim("profile")} ${config.profile}`);
|
|
260
|
+
if (config.projects) {
|
|
261
|
+
console.log(`\n ${dim("linked projects")}`);
|
|
262
|
+
for (const [dir, entry] of Object.entries(config.projects)) {
|
|
263
|
+
const detail = typeof entry === "string" ? entry : `${entry.projectSlug ?? ""} ${entry.secretPath ?? ""}`.trim();
|
|
264
|
+
console.log(` ${dir.padEnd(24)} ${dim(detail)}`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
const cmdProfiles = () => {
|
|
269
|
+
const profiles = listProfiles();
|
|
270
|
+
if (!profiles.length)
|
|
271
|
+
die("No profiles. Run `sanctum login` first.");
|
|
272
|
+
for (const p of profiles) {
|
|
273
|
+
console.log(`${p.isDefault ? "*" : " "} ${p.name.padEnd(20)} ${p.baseUrl}`);
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
const cmdProjects = async (args) => {
|
|
277
|
+
const sub = args[0] ?? "list";
|
|
278
|
+
const profile = takeFlag(args, "--profile");
|
|
279
|
+
const sdk = await getClient(profile);
|
|
280
|
+
if (sub === "list") {
|
|
281
|
+
const { projects } = await sdk.projects.list();
|
|
282
|
+
for (const p of projects)
|
|
283
|
+
console.log(`${p.slug.padEnd(32)} ${p.name} ${p.id}`);
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
die(`Unknown subcommand: projects ${sub}`);
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
const cmdEnvs = async (args) => {
|
|
290
|
+
const config = resolveConfig();
|
|
291
|
+
const profile = takeFlag(args, "--profile") ?? config.profile;
|
|
292
|
+
const sdk = await getClient(profile);
|
|
293
|
+
const projectId = config.projectId ?? (await sdk.projects.getBySlug(config.projectSlug)).id;
|
|
294
|
+
const { environments } = await sdk.projects.listEnvironments(projectId);
|
|
295
|
+
for (const e of environments)
|
|
296
|
+
console.log(`${e.slug.padEnd(16)} ${e.name}`);
|
|
297
|
+
};
|
|
298
|
+
/** Create a secret, falling back to update only when the server says it already exists. */
|
|
299
|
+
const upsertSecret = async (sdk, key, secretValue, scope) => {
|
|
300
|
+
try {
|
|
301
|
+
const r = await sdk.secrets.create(key, { ...scope, secretValue });
|
|
302
|
+
return { verb: "Created", approval: r.approval };
|
|
303
|
+
}
|
|
304
|
+
catch (err) {
|
|
305
|
+
const conflict = err instanceof SanctumApiError && /exist|conflict/i.test(err.message);
|
|
306
|
+
if (!conflict)
|
|
307
|
+
throw err;
|
|
308
|
+
const r = await sdk.secrets.update(key, { ...scope, secretValue });
|
|
309
|
+
return { verb: "Updated", approval: r.approval };
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
const cmdSecrets = async (args) => {
|
|
313
|
+
const sub = args.shift();
|
|
314
|
+
const env = takeFlag(args, "--env");
|
|
315
|
+
const path = takeFlag(args, "--path");
|
|
316
|
+
const config = resolveConfig();
|
|
317
|
+
const profile = takeFlag(args, "--profile") ?? config.profile;
|
|
318
|
+
const sdk = await getClient(profile);
|
|
319
|
+
const scope = (p) => scopeQuery(config, p ?? path ?? config.secretPath ?? "/", env);
|
|
320
|
+
switch (sub) {
|
|
321
|
+
case "list": {
|
|
322
|
+
const merged = await fetchMergedSecrets(sdk, config, env);
|
|
323
|
+
const keys = Object.keys(merged).sort();
|
|
324
|
+
for (const k of keys)
|
|
325
|
+
console.log(k);
|
|
326
|
+
console.log(dim(`\n${keys.length} secrets`));
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
case "get": {
|
|
330
|
+
const key = args[0];
|
|
331
|
+
if (!key)
|
|
332
|
+
die("Usage: sanctum secrets get <KEY>");
|
|
333
|
+
const { secret } = await sdk.secrets.get(key, scope());
|
|
334
|
+
console.log(secret.secretValue ?? "");
|
|
335
|
+
break;
|
|
336
|
+
}
|
|
337
|
+
case "set": {
|
|
338
|
+
const pair = args[0];
|
|
339
|
+
if (!pair?.includes("="))
|
|
340
|
+
die("Usage: sanctum secrets set <KEY>=<value>");
|
|
341
|
+
const [key, ...rest] = pair.split("=");
|
|
342
|
+
const value = rest.join("=");
|
|
343
|
+
const result = await upsertSecret(sdk, key, value, scope());
|
|
344
|
+
if (result.approval)
|
|
345
|
+
console.log(`${yellow("?")} ${key} queued for approval${result.approval.slug ? ` (${result.approval.slug})` : ""}`);
|
|
346
|
+
else
|
|
347
|
+
ok(`${result.verb} ${key}`);
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
case "rm":
|
|
351
|
+
case "delete": {
|
|
352
|
+
const key = args[0];
|
|
353
|
+
if (!key)
|
|
354
|
+
die("Usage: sanctum secrets rm <KEY>");
|
|
355
|
+
const result = await sdk.secrets.delete(key, scope());
|
|
356
|
+
if (result.approval)
|
|
357
|
+
console.log(`${yellow("?")} deletion of ${key} queued for approval${result.approval.slug ? ` (${result.approval.slug})` : ""}`);
|
|
358
|
+
else
|
|
359
|
+
ok(`Deleted ${key}`);
|
|
360
|
+
break;
|
|
361
|
+
}
|
|
362
|
+
default:
|
|
363
|
+
die("Usage: sanctum secrets <list|get|set|rm>");
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
const maskValue = (v) => (v.length <= 4 ? "****" : `${v.slice(0, 2)}***${v.slice(-2)}`);
|
|
367
|
+
const diffOne = async (sdk, config, file, env, showValues) => {
|
|
368
|
+
const local = existsSync(file) ? sdk.dotenv.parseDotenv(readFileSync(file, "utf8")) : {};
|
|
369
|
+
const remote = await fetchMergedSecrets(sdk, config, env);
|
|
370
|
+
const localKeys = new Set(Object.keys(local));
|
|
371
|
+
const remoteKeys = new Set(Object.keys(remote));
|
|
372
|
+
const allKeys = [...new Set([...localKeys, ...remoteKeys])].sort();
|
|
373
|
+
const added = [];
|
|
374
|
+
const changed = [];
|
|
375
|
+
const removed = [];
|
|
376
|
+
let unchanged = 0;
|
|
377
|
+
for (const key of allKeys) {
|
|
378
|
+
if (localKeys.has(key) && !remoteKeys.has(key))
|
|
379
|
+
added.push(key);
|
|
380
|
+
else if (!localKeys.has(key) && remoteKeys.has(key))
|
|
381
|
+
removed.push(key);
|
|
382
|
+
else if (local[key] !== remote[key])
|
|
383
|
+
changed.push(key);
|
|
384
|
+
else
|
|
385
|
+
unchanged += 1;
|
|
386
|
+
}
|
|
387
|
+
const renderValue = (v) => (showValues ? v : maskValue(v));
|
|
388
|
+
for (const k of added)
|
|
389
|
+
console.log(`${green("+")} ${k} ${dim("local only")}${showValues ? `: ${local[k]}` : ""}`);
|
|
390
|
+
for (const k of changed) {
|
|
391
|
+
console.log(`${yellow("~")} ${k} local=${renderValue(local[k])} remote=${renderValue(remote[k])}`);
|
|
392
|
+
}
|
|
393
|
+
for (const k of removed)
|
|
394
|
+
console.log(`${red("-")} ${k} ${dim("remote only")}`);
|
|
395
|
+
console.log(`\n${added.length} to add, ${changed.length} to update, ${removed.length} remote-only, ${unchanged} unchanged` +
|
|
396
|
+
(existsSync(file) ? ` [${file} vs ${config.projectSlug ?? config.projectId}/${config.environment ?? "dev"}]` : ` [no ${file} — showing remote-only]`));
|
|
397
|
+
};
|
|
398
|
+
const cmdDiff = async (args) => {
|
|
399
|
+
const env = takeFlag(args, "--env");
|
|
400
|
+
const showValues = hasFlag(args, "--values");
|
|
401
|
+
const all = hasFlag(args, "--all");
|
|
402
|
+
const config = resolveConfig();
|
|
403
|
+
const profile = takeFlag(args, "--profile") ?? config.profile;
|
|
404
|
+
const file = args[0] ?? ".env";
|
|
405
|
+
const sdk = await getClient(profile);
|
|
406
|
+
// monorepo: root config registers linked children — offer a scope pick
|
|
407
|
+
if (config.projects && Object.keys(config.projects).length) {
|
|
408
|
+
const rootDir = dirname(config.configFile);
|
|
409
|
+
const entries = Object.keys(config.projects);
|
|
410
|
+
let selected = all ? entries : null;
|
|
411
|
+
if (!selected && process.stdin.isTTY && !args[0]) {
|
|
412
|
+
const choice = await select("Diff which project?", [
|
|
413
|
+
{ label: "All", value: "__all__" },
|
|
414
|
+
{ label: "(this directory)", value: "__here__" },
|
|
415
|
+
...entries.map((e) => ({ label: e, value: e }))
|
|
416
|
+
]);
|
|
417
|
+
if (choice === "__all__")
|
|
418
|
+
selected = entries;
|
|
419
|
+
else if (choice === "__here__")
|
|
420
|
+
selected = [];
|
|
421
|
+
else
|
|
422
|
+
selected = [choice];
|
|
423
|
+
}
|
|
424
|
+
if (!selected)
|
|
425
|
+
selected = [];
|
|
426
|
+
for (const rel of selected) {
|
|
427
|
+
console.log(`\n== ${rel} ==`);
|
|
428
|
+
const childDir = join(rootDir, rel);
|
|
429
|
+
const childConfig = resolveConfig(childDir);
|
|
430
|
+
await diffOne(sdk, childConfig, join(childDir, file), env, showValues);
|
|
431
|
+
}
|
|
432
|
+
if (selected.length)
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
await diffOne(sdk, config, file, env, showValues);
|
|
436
|
+
};
|
|
437
|
+
const cmdExport = async (args) => {
|
|
438
|
+
const env = takeFlag(args, "--env");
|
|
439
|
+
const format = takeFlag(args, "--format") ?? "dotenv";
|
|
440
|
+
const outFile = takeFlag(args, "--out");
|
|
441
|
+
const config = resolveConfig();
|
|
442
|
+
const profile = takeFlag(args, "--profile") ?? config.profile;
|
|
443
|
+
const sdk = await getClient(profile);
|
|
444
|
+
const merged = await fetchMergedSecrets(sdk, config, env);
|
|
445
|
+
let output;
|
|
446
|
+
if (format === "json") {
|
|
447
|
+
output = JSON.stringify(merged, null, 2);
|
|
448
|
+
}
|
|
449
|
+
else {
|
|
450
|
+
output = sdk.dotenv.renderDotenv(merged);
|
|
451
|
+
}
|
|
452
|
+
if (outFile) {
|
|
453
|
+
writeFileSync(outFile, output + "\n");
|
|
454
|
+
console.log(`Wrote ${Object.keys(merged).length} secrets to ${outFile}`);
|
|
455
|
+
}
|
|
456
|
+
else {
|
|
457
|
+
console.log(output);
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
/** Merge remote secrets into existing dotenv text: update values in place, append new keys, keep everything else. */
|
|
461
|
+
const mergeDotenv = (existing, remote) => {
|
|
462
|
+
const applied = new Set();
|
|
463
|
+
let updated = 0;
|
|
464
|
+
const lines = existing.split(/\r?\n/).map((line) => {
|
|
465
|
+
const m = line.match(/^(\s*(?:export\s+)?)([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
|
466
|
+
if (!m)
|
|
467
|
+
return line;
|
|
468
|
+
const key = m[2];
|
|
469
|
+
if (!(key in remote))
|
|
470
|
+
return line;
|
|
471
|
+
applied.add(key);
|
|
472
|
+
if (m[3] === remote[key])
|
|
473
|
+
return line;
|
|
474
|
+
updated += 1;
|
|
475
|
+
return `${m[1]}${key}=${remote[key]}`;
|
|
476
|
+
});
|
|
477
|
+
const appended = Object.keys(remote).filter((k) => !applied.has(k));
|
|
478
|
+
const out = [...lines];
|
|
479
|
+
if (out.length && out[out.length - 1] === "")
|
|
480
|
+
out.pop();
|
|
481
|
+
for (const k of appended)
|
|
482
|
+
out.push(`${k}=${remote[k]}`);
|
|
483
|
+
return { text: out.join("\n"), updated, added: appended.length };
|
|
484
|
+
};
|
|
485
|
+
const pullOne = async (sdk, config, outFile, env, skipConfirm = false) => {
|
|
486
|
+
const merged = await fetchMergedSecrets(sdk, config, env);
|
|
487
|
+
if (existsSync(outFile)) {
|
|
488
|
+
const { text, updated, added } = mergeDotenv(readFileSync(outFile, "utf8"), merged);
|
|
489
|
+
if (!updated && !added) {
|
|
490
|
+
ok(`${outFile} already in sync — no changes.`);
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
if (!skipConfirm && process.stdin.isTTY) {
|
|
494
|
+
const proceed = await select(`${outFile}: update ${updated}, add ${added} — apply?`, [
|
|
495
|
+
{ label: "Yes", value: true },
|
|
496
|
+
{ label: "No", value: false }
|
|
497
|
+
]);
|
|
498
|
+
if (!proceed) {
|
|
499
|
+
console.log("Skipped.");
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
writeFileSync(outFile, text.endsWith("\n") ? text : text + "\n");
|
|
504
|
+
ok(`Updated ${updated}, added ${added} in ${outFile}`);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
writeFileSync(outFile, sdk.dotenv.renderDotenv(merged) + "\n");
|
|
508
|
+
ok(`Pulled ${Object.keys(merged).length} secrets to ${outFile}`);
|
|
509
|
+
};
|
|
510
|
+
const cmdPull = async (args) => {
|
|
511
|
+
const env = takeFlag(args, "--env");
|
|
512
|
+
const all = hasFlag(args, "--all");
|
|
513
|
+
const yes = hasFlag(args, "--yes") || hasFlag(args, "-y") || hasFlag(args, "--force");
|
|
514
|
+
const outFlag = takeFlag(args, "--out");
|
|
515
|
+
const config = resolveConfig();
|
|
516
|
+
const profile = takeFlag(args, "--profile") ?? config.profile;
|
|
517
|
+
const file = outFlag ?? args[0] ?? ".env";
|
|
518
|
+
const sdk = await getClient(profile);
|
|
519
|
+
if (config.projects && Object.keys(config.projects).length) {
|
|
520
|
+
const rootDir = dirname(config.configFile);
|
|
521
|
+
const entries = Object.keys(config.projects);
|
|
522
|
+
let selected = all ? entries : null;
|
|
523
|
+
if (!selected && process.stdin.isTTY && !args[0] && !outFlag) {
|
|
524
|
+
const choice = await select("Pull which project?", [
|
|
525
|
+
{ label: "All", value: "__all__" },
|
|
526
|
+
{ label: "(this directory)", value: "__here__" },
|
|
527
|
+
...entries.map((e) => ({ label: e, value: e }))
|
|
528
|
+
]);
|
|
529
|
+
if (choice === "__all__")
|
|
530
|
+
selected = entries;
|
|
531
|
+
else if (choice === "__here__")
|
|
532
|
+
selected = [];
|
|
533
|
+
else
|
|
534
|
+
selected = [choice];
|
|
535
|
+
}
|
|
536
|
+
if (!selected)
|
|
537
|
+
selected = [];
|
|
538
|
+
for (const rel of selected) {
|
|
539
|
+
const childDir = join(rootDir, rel);
|
|
540
|
+
console.log(`\n== ${rel} ==`);
|
|
541
|
+
await pullOne(sdk, resolveConfig(childDir), join(childDir, file), env, yes);
|
|
542
|
+
}
|
|
543
|
+
if (selected.length) {
|
|
544
|
+
console.log("note: pulled files contain real secret values — keep them out of git.");
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
await pullOne(sdk, config, file, env, yes);
|
|
549
|
+
console.log("note: pulled files contain real secret values — keep them out of git.");
|
|
550
|
+
};
|
|
551
|
+
const cmdRun = async (args) => {
|
|
552
|
+
const separator = args.indexOf("--");
|
|
553
|
+
const head = separator === -1 ? args : args.slice(0, separator);
|
|
554
|
+
const env = takeFlag(head, "--env");
|
|
555
|
+
const profileFlag = takeFlag(head, "--profile");
|
|
556
|
+
const command = separator === -1 ? head : args.slice(separator + 1);
|
|
557
|
+
if (!command.length)
|
|
558
|
+
die("Usage: sanctum run [--env dev] [--profile x] -- <command> [args...]");
|
|
559
|
+
const config = resolveConfig();
|
|
560
|
+
const profile = profileFlag ?? config.profile;
|
|
561
|
+
const sdk = await getClient(profile);
|
|
562
|
+
const merged = await fetchMergedSecrets(sdk, config, env);
|
|
563
|
+
if (!Object.keys(merged).length) {
|
|
564
|
+
console.error(`warning: no secrets resolved for ${config.projectSlug ?? config.projectId}/${config.environment ?? "dev"} ` +
|
|
565
|
+
`(paths: ${config.paths.join(", ")}) — check the environment and paths exist.`);
|
|
566
|
+
}
|
|
567
|
+
const child = spawn(command[0], command.slice(1), {
|
|
568
|
+
env: { ...process.env, ...merged },
|
|
569
|
+
stdio: "inherit",
|
|
570
|
+
shell: process.platform === "win32"
|
|
571
|
+
});
|
|
572
|
+
child.on("exit", (code) => process.exit(code ?? 0));
|
|
573
|
+
};
|
|
574
|
+
const cmdEnvFile = async (args) => {
|
|
575
|
+
const dryRun = hasFlag(args, "--dry-run");
|
|
576
|
+
const assumeYes = hasFlag(args, "--yes") || hasFlag(args, "-y");
|
|
577
|
+
const env = takeFlag(args, "--env");
|
|
578
|
+
const file = args[0] ?? ".env";
|
|
579
|
+
if (!existsSync(file))
|
|
580
|
+
die(`${file} not found. Create it, or fetch remote secrets first with \`sanctum pull ${file}\`.`);
|
|
581
|
+
const config = resolveConfig();
|
|
582
|
+
const profile = takeFlag(args, "--profile") ?? config.profile;
|
|
583
|
+
const sdk = await getClient(profile);
|
|
584
|
+
const values = sdk.dotenv.parseDotenv(readFileSync(file, "utf8"));
|
|
585
|
+
const keys = Object.keys(values);
|
|
586
|
+
if (!keys.length)
|
|
587
|
+
die(`No KEY=value pairs found in ${file}. Nothing to push.`);
|
|
588
|
+
// preview against remote so the user sees exactly what changes
|
|
589
|
+
const remote = await fetchMergedSecrets(sdk, config, env);
|
|
590
|
+
const toAdd = keys.filter((k) => !(k in remote));
|
|
591
|
+
const toUpdate = keys.filter((k) => k in remote && remote[k] !== values[k]);
|
|
592
|
+
const same = keys.length - toAdd.length - toUpdate.length;
|
|
593
|
+
const remoteOnly = Object.keys(remote).filter((k) => !(k in values));
|
|
594
|
+
console.log(`${dim(file)} -> ${bold(config.projectSlug ?? config.projectId ?? "?")}/${env ?? config.environment ?? "dev"}${config.secretPath ?? "/"}`);
|
|
595
|
+
for (const k of toAdd)
|
|
596
|
+
console.log(`${green("+")} ${k} ${dim("new")}`);
|
|
597
|
+
for (const k of toUpdate)
|
|
598
|
+
console.log(`${yellow("~")} ${k} ${dim(maskValue(values[k]))}`);
|
|
599
|
+
console.log(`${toAdd.length} to add, ${toUpdate.length} to update, ${same} unchanged, ${remoteOnly.length} remote-only (kept)`);
|
|
600
|
+
if (dryRun)
|
|
601
|
+
return;
|
|
602
|
+
if (!toAdd.length && !toUpdate.length) {
|
|
603
|
+
console.log("Nothing to push.");
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
if (!assumeYes && process.stdin.isTTY) {
|
|
607
|
+
const ok = await select("Apply these changes?", [
|
|
608
|
+
{ label: "Yes", value: true },
|
|
609
|
+
{ label: "No", value: false }
|
|
610
|
+
]);
|
|
611
|
+
if (!ok) {
|
|
612
|
+
console.log("Aborted.");
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
const scope = { ...scopeQuery(config, config.secretPath ?? "/", env), secretPath: config.secretPath ?? "/" };
|
|
617
|
+
let pushed = 0;
|
|
618
|
+
let approvals = 0;
|
|
619
|
+
for (const key of [...toAdd, ...toUpdate]) {
|
|
620
|
+
const result = await upsertSecret(sdk, key, values[key], scope);
|
|
621
|
+
if (result.approval) {
|
|
622
|
+
approvals += 1;
|
|
623
|
+
console.log(`${yellow("?")} ${key} ${dim(`queued for approval${result.approval.slug ? ` (${result.approval.slug})` : ""}`)}`);
|
|
624
|
+
}
|
|
625
|
+
else {
|
|
626
|
+
pushed += 1;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
ok(`Pushed ${pushed} secrets to ${config.secretPath ?? "/"}${approvals ? `, ${approvals} pending approval` : ""}`);
|
|
630
|
+
};
|
|
631
|
+
const AGENTS_SNIPPET = `
|
|
632
|
+
## Secrets management (Sanctum)
|
|
633
|
+
|
|
634
|
+
This project uses the Sanctum CLI for secrets — do NOT create or commit .env files
|
|
635
|
+
with real values.
|
|
636
|
+
|
|
637
|
+
- \`sanctum-config.json\` links this directory to a Sanctum project/environment/secret
|
|
638
|
+
path. It is committed and contains no secrets.
|
|
639
|
+
- Run the app with \`sanctum run -- <command>\` (e.g. \`sanctum run -- npm run dev\`) —
|
|
640
|
+
secrets are injected as env vars at launch.
|
|
641
|
+
- \`sanctum secrets list\` / \`get <KEY>\` / \`set <KEY>=<v>\` read and write remote secrets.
|
|
642
|
+
- \`sanctum diff\` compares a local file against remote; \`sanctum push\` uploads with a
|
|
643
|
+
preview. Never paste secret values into chat, commits, or docs.
|
|
644
|
+
`;
|
|
645
|
+
const cmdAgents = () => {
|
|
646
|
+
const target = join(process.cwd(), "AGENTS.md");
|
|
647
|
+
const existing = existsSync(target) ? readFileSync(target, "utf8") : "";
|
|
648
|
+
if (existing.includes("Secrets management (Sanctum)")) {
|
|
649
|
+
console.log("AGENTS.md already has a Sanctum section.");
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
writeFileSync(target, existing.trimEnd() + "\n" + AGENTS_SNIPPET);
|
|
653
|
+
console.log(`Appended Sanctum section to ${target}`);
|
|
654
|
+
};
|
|
655
|
+
const HELP = `sanctum — CLI for the Sanctum secrets platform
|
|
656
|
+
|
|
657
|
+
Usage:
|
|
658
|
+
sanctum login --token <token> | --client-id <id> --client-secret <secret> [--base-url <url>] [--profile name]
|
|
659
|
+
sanctum profiles list saved credential profiles
|
|
660
|
+
sanctum init [--project <id-or-slug>] [--env dev] [--path /apps/api] [--imports /shared] [--profile name]
|
|
661
|
+
(interactive picker when --project is omitted)
|
|
662
|
+
sanctum projects list [--profile name]
|
|
663
|
+
sanctum status show resolved config + linked monorepo projects
|
|
664
|
+
sanctum agents add a Sanctum usage section to ./AGENTS.md
|
|
665
|
+
sanctum envs
|
|
666
|
+
sanctum secrets list|get <KEY>|set <KEY>=<value>|rm <KEY> [--env x] [--path /x] [--profile name]
|
|
667
|
+
sanctum export [--env x] [--format dotenv|json] [--out file]
|
|
668
|
+
sanctum diff [file] [--env x] [--values] [--all] compare local .env against remote secrets
|
|
669
|
+
sanctum pull [file] [--env x] [--all] [--yes] fetch remote secrets into a local .env
|
|
670
|
+
sanctum push [file] [--env x] [--dry-run] [--yes] push a .env file's keys to your configured secretPath
|
|
671
|
+
sanctum run [--env x] [--profile x] -- <cmd>
|
|
672
|
+
|
|
673
|
+
Discovery: commands read the nearest sanctum-config.json upward from cwd.
|
|
674
|
+
Ancestor configs merge in root->leaf order so shared folders apply repo-wide.
|
|
675
|
+
The config file is safe to commit — credentials live in ~/.sanctum/credentials.json,
|
|
676
|
+
keyed by profile. Profile resolution: --profile > SANCTUM_PROFILE > config "profile" > "default".
|
|
677
|
+
`;
|
|
678
|
+
const main = async () => {
|
|
679
|
+
const args = process.argv.slice(2);
|
|
680
|
+
const cmd = args.shift();
|
|
681
|
+
try {
|
|
682
|
+
switch (cmd) {
|
|
683
|
+
case "login": return await cmdLogin(args);
|
|
684
|
+
case "profiles": return cmdProfiles();
|
|
685
|
+
case "init": return await cmdInit(args);
|
|
686
|
+
case "projects": return await cmdProjects(args);
|
|
687
|
+
case "status": return cmdStatus();
|
|
688
|
+
case "agents": return cmdAgents();
|
|
689
|
+
case "envs": return await cmdEnvs(args);
|
|
690
|
+
case "secrets": return await cmdSecrets(args);
|
|
691
|
+
case "export": return await cmdExport(args);
|
|
692
|
+
case "diff": return await cmdDiff(args);
|
|
693
|
+
case "pull": return await cmdPull(args);
|
|
694
|
+
case "push": return await cmdEnvFile(args);
|
|
695
|
+
case "run": return await cmdRun(args);
|
|
696
|
+
case "whoami": {
|
|
697
|
+
const profileFlag = takeFlag(args, "--profile");
|
|
698
|
+
const credentials = loadCredentials(profileFlag);
|
|
699
|
+
if (!credentials)
|
|
700
|
+
return die("Not logged in.");
|
|
701
|
+
const claims = credentials.accessToken ? decodeJwtPayload(credentials.accessToken) : null;
|
|
702
|
+
const exp = claims?.exp ? new Date(Number(claims.exp) * 1000) : null;
|
|
703
|
+
const valid = !exp || exp.getTime() > Date.now();
|
|
704
|
+
console.log(`${green("●")} ${bold("logged in")} ${dim(`as profile`)} ${cyan(profileFlag ?? process.env.SANCTUM_PROFILE ?? "default")}\n`);
|
|
705
|
+
console.log(` ${dim("instance")} ${credentials.baseUrl}`);
|
|
706
|
+
if (credentials.clientId)
|
|
707
|
+
console.log(` ${dim("clientId")} ${credentials.clientId}`);
|
|
708
|
+
if (claims?.identityId)
|
|
709
|
+
console.log(` ${dim("identity")} ${String(claims.identityId)}`);
|
|
710
|
+
if (claims?.orgId)
|
|
711
|
+
console.log(` ${dim("org")} ${String(claims.orgId)}`);
|
|
712
|
+
if (exp) {
|
|
713
|
+
const expired = exp.getTime() <= Date.now();
|
|
714
|
+
console.log(` ${dim("token")} ${expired ? red("expired") : "valid until"} ${exp.toLocaleString()}`);
|
|
715
|
+
}
|
|
716
|
+
if (!valid)
|
|
717
|
+
console.log(dim("\n token expired — next command will re-authenticate via clientId/secret"));
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
case "--version":
|
|
721
|
+
case "-v":
|
|
722
|
+
case "version":
|
|
723
|
+
console.log(VERSION);
|
|
724
|
+
return;
|
|
725
|
+
default:
|
|
726
|
+
console.log(HELP);
|
|
727
|
+
if (cmd && cmd !== "help" && cmd !== "--help")
|
|
728
|
+
process.exit(1);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
catch (err) {
|
|
732
|
+
die(err instanceof Error ? err.message : String(err));
|
|
733
|
+
}
|
|
734
|
+
};
|
|
735
|
+
void main();
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
const CONFIG_FILENAME = "sanctum-config.json";
|
|
5
|
+
const CREDENTIALS_DIR = ".sanctum";
|
|
6
|
+
const CREDENTIALS_FILE = "credentials.json";
|
|
7
|
+
const credentialsPath = () => join(homedir(), CREDENTIALS_DIR, CREDENTIALS_FILE);
|
|
8
|
+
const readStore = () => {
|
|
9
|
+
const path = credentialsPath();
|
|
10
|
+
if (!existsSync(path))
|
|
11
|
+
return { profiles: {} };
|
|
12
|
+
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
13
|
+
// migrate flat { baseUrl, accessToken, ... } shape to profiles
|
|
14
|
+
if ("baseUrl" in raw && typeof raw.baseUrl === "string") {
|
|
15
|
+
return { default: "default", profiles: { default: raw } };
|
|
16
|
+
}
|
|
17
|
+
return raw;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Credential resolution order:
|
|
21
|
+
* SANCTUM_TOKEN / SANCTUM_CLIENT_* env vars > explicit profile > default profile
|
|
22
|
+
*/
|
|
23
|
+
export const loadCredentials = (profile) => {
|
|
24
|
+
const baseUrl = process.env.SANCTUM_BASE_URL ?? "http://localhost:4000";
|
|
25
|
+
if (process.env.SANCTUM_TOKEN) {
|
|
26
|
+
return { baseUrl, accessToken: process.env.SANCTUM_TOKEN };
|
|
27
|
+
}
|
|
28
|
+
if (process.env.SANCTUM_CLIENT_ID && process.env.SANCTUM_CLIENT_SECRET) {
|
|
29
|
+
return {
|
|
30
|
+
baseUrl,
|
|
31
|
+
clientId: process.env.SANCTUM_CLIENT_ID,
|
|
32
|
+
clientSecret: process.env.SANCTUM_CLIENT_SECRET
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
const store = readStore();
|
|
36
|
+
const name = profile ?? process.env.SANCTUM_PROFILE ?? store.default ?? "default";
|
|
37
|
+
const stored = store.profiles[name];
|
|
38
|
+
if (!stored)
|
|
39
|
+
return null;
|
|
40
|
+
return { ...stored, baseUrl: process.env.SANCTUM_BASE_URL ?? stored.baseUrl };
|
|
41
|
+
};
|
|
42
|
+
export const saveCredentials = (credentials, profile = "default") => {
|
|
43
|
+
const store = readStore();
|
|
44
|
+
store.profiles[profile] = credentials;
|
|
45
|
+
store.default = store.default ?? profile;
|
|
46
|
+
const path = credentialsPath();
|
|
47
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
48
|
+
writeFileSync(path, JSON.stringify(store, null, 2) + "\n", { mode: 0o600 });
|
|
49
|
+
};
|
|
50
|
+
export const listProfiles = () => {
|
|
51
|
+
const store = readStore();
|
|
52
|
+
return Object.entries(store.profiles).map(([name, c]) => ({
|
|
53
|
+
name,
|
|
54
|
+
isDefault: store.default === name,
|
|
55
|
+
baseUrl: c.baseUrl
|
|
56
|
+
}));
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* Walk up from `startDir` collecting every sanctum-config.json.
|
|
60
|
+
* The nearest config is authoritative for projectId/environment/secretPath.
|
|
61
|
+
* Ancestor configs contribute their secretPath + imports so shared folders
|
|
62
|
+
* declared higher in a monorepo merge in (deepest path wins on key conflicts).
|
|
63
|
+
*/
|
|
64
|
+
export const resolveConfig = (startDir = process.cwd()) => {
|
|
65
|
+
const configs = [];
|
|
66
|
+
let dir = resolve(startDir);
|
|
67
|
+
for (;;) {
|
|
68
|
+
const file = join(dir, CONFIG_FILENAME);
|
|
69
|
+
if (existsSync(file)) {
|
|
70
|
+
configs.push({ dir, config: JSON.parse(readFileSync(file, "utf8")) });
|
|
71
|
+
}
|
|
72
|
+
const parent = dirname(dir);
|
|
73
|
+
if (parent === dir)
|
|
74
|
+
break;
|
|
75
|
+
dir = parent;
|
|
76
|
+
}
|
|
77
|
+
if (!configs.length) {
|
|
78
|
+
throw new Error(`No ${CONFIG_FILENAME} found in this directory or any parent. Run \`sanctum init\` first.`);
|
|
79
|
+
}
|
|
80
|
+
// If the nearest config's own projects map has an entry covering cwd,
|
|
81
|
+
// merge it as a synthetic leaf config (single-config monorepo support).
|
|
82
|
+
let primary = configs[0].config;
|
|
83
|
+
let primaryFile = join(configs[0].dir, CONFIG_FILENAME);
|
|
84
|
+
for (const { dir, config } of configs) {
|
|
85
|
+
if (!config.projects)
|
|
86
|
+
continue;
|
|
87
|
+
for (const [rel, entry] of Object.entries(config.projects)) {
|
|
88
|
+
const childDir = resolve(dir, rel);
|
|
89
|
+
if (resolve(startDir) === childDir || resolve(startDir).startsWith(childDir + "\\") || resolve(startDir).startsWith(childDir + "/")) {
|
|
90
|
+
if (typeof entry === "object") {
|
|
91
|
+
primary = { ...config, projects: undefined, ...entry };
|
|
92
|
+
primaryFile = join(dir, rel, CONFIG_FILENAME);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const paths = [];
|
|
98
|
+
// configs are collected leaf -> root; iterate root -> leaf so deeper
|
|
99
|
+
// configs (and their imports) win on key conflicts
|
|
100
|
+
for (const { config } of [...configs].reverse()) {
|
|
101
|
+
for (const imp of config.imports ?? []) {
|
|
102
|
+
if (!paths.includes(imp))
|
|
103
|
+
paths.push(imp);
|
|
104
|
+
}
|
|
105
|
+
const p = config.secretPath ?? "/";
|
|
106
|
+
if (!paths.includes(p))
|
|
107
|
+
paths.push(p);
|
|
108
|
+
}
|
|
109
|
+
if (primary !== configs[0].config) {
|
|
110
|
+
for (const imp of primary.imports ?? []) {
|
|
111
|
+
if (!paths.includes(imp))
|
|
112
|
+
paths.push(imp);
|
|
113
|
+
}
|
|
114
|
+
const p = primary.secretPath ?? "/";
|
|
115
|
+
if (!paths.includes(p))
|
|
116
|
+
paths.push(p);
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
...primary,
|
|
120
|
+
paths,
|
|
121
|
+
configFile: primaryFile
|
|
122
|
+
};
|
|
123
|
+
};
|
|
124
|
+
/**
|
|
125
|
+
* Register a child config in the outermost ancestor sanctum-config.json so a
|
|
126
|
+
* monorepo root tracks every linked app: `projects: { "apps/api": "apps/api/sanctum-config.json" }`.
|
|
127
|
+
* Returns the ancestor config file that was updated, or null if none exists.
|
|
128
|
+
*/
|
|
129
|
+
export const registerChildConfig = (childDir, startDir = childDir) => {
|
|
130
|
+
const parentConfigs = [];
|
|
131
|
+
let dir = dirname(resolve(startDir));
|
|
132
|
+
for (;;) {
|
|
133
|
+
const file = join(dir, CONFIG_FILENAME);
|
|
134
|
+
if (existsSync(file))
|
|
135
|
+
parentConfigs.push({ dir, file });
|
|
136
|
+
const parent = dirname(dir);
|
|
137
|
+
if (parent === dir)
|
|
138
|
+
break;
|
|
139
|
+
dir = parent;
|
|
140
|
+
}
|
|
141
|
+
if (!parentConfigs.length)
|
|
142
|
+
return null;
|
|
143
|
+
const root = parentConfigs[parentConfigs.length - 1];
|
|
144
|
+
const config = JSON.parse(readFileSync(root.file, "utf8"));
|
|
145
|
+
const rel = resolve(childDir) === resolve(root.dir)
|
|
146
|
+
? "."
|
|
147
|
+
: resolve(childDir).slice(resolve(root.dir).length + 1).replace(/\\/g, "/");
|
|
148
|
+
config.projects = { ...(config.projects ?? {}), [rel]: `${rel}/${CONFIG_FILENAME}` };
|
|
149
|
+
writeFileSync(root.file, JSON.stringify(config, null, 2) + "\n");
|
|
150
|
+
return root.file;
|
|
151
|
+
};
|
|
152
|
+
export const writeProjectConfig = (dir, config) => {
|
|
153
|
+
const file = join(dir, CONFIG_FILENAME);
|
|
154
|
+
writeFileSync(file, JSON.stringify(config, null, 2) + "\n");
|
|
155
|
+
return file;
|
|
156
|
+
};
|
package/dist/prompt.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { createInterface } from "node:readline";
|
|
2
|
+
const hideCursor = () => process.stdout.write("\x1b[?25l");
|
|
3
|
+
const showCursor = () => process.stdout.write("\x1b[?25h");
|
|
4
|
+
export const select = async (question, choices) => {
|
|
5
|
+
if (!process.stdin.isTTY) {
|
|
6
|
+
throw new Error("Interactive prompt requires a TTY. Pass flags instead (e.g. --project, --env).");
|
|
7
|
+
}
|
|
8
|
+
return new Promise((resolve) => {
|
|
9
|
+
let index = 0;
|
|
10
|
+
let rendered = 0;
|
|
11
|
+
const render = () => {
|
|
12
|
+
if (rendered > 0)
|
|
13
|
+
process.stdout.write(`\x1b[${rendered}A`);
|
|
14
|
+
const lines = [
|
|
15
|
+
`? ${question}`,
|
|
16
|
+
...choices.map((c, i) => ` ${i === index ? "❯" : " "} ${c.label}${i === index && c.hint ? ` ${c.hint}` : ""}`)
|
|
17
|
+
];
|
|
18
|
+
process.stdout.write(lines.map((l) => `\x1b[2K${l}`).join("\n") + "\n");
|
|
19
|
+
rendered = lines.length;
|
|
20
|
+
};
|
|
21
|
+
const finish = () => {
|
|
22
|
+
process.stdin.setRawMode(false);
|
|
23
|
+
process.stdin.pause();
|
|
24
|
+
process.stdin.removeListener("data", onData);
|
|
25
|
+
showCursor();
|
|
26
|
+
resolve(choices[index].value);
|
|
27
|
+
};
|
|
28
|
+
const onData = (chunk) => {
|
|
29
|
+
const key = chunk.toString("utf8");
|
|
30
|
+
if (key === "\r" || key === "\n")
|
|
31
|
+
return finish();
|
|
32
|
+
if (key === "\u0003") {
|
|
33
|
+
showCursor();
|
|
34
|
+
process.exit(130);
|
|
35
|
+
}
|
|
36
|
+
if (key === "\u001b[A" || key === "k")
|
|
37
|
+
index = (index - 1 + choices.length) % choices.length;
|
|
38
|
+
else if (key === "\u001b[B" || key === "j")
|
|
39
|
+
index = (index + 1) % choices.length;
|
|
40
|
+
else if (key >= "1" && key <= "9") {
|
|
41
|
+
const n = Number(key) - 1;
|
|
42
|
+
if (n < choices.length)
|
|
43
|
+
index = n;
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
render();
|
|
49
|
+
};
|
|
50
|
+
hideCursor();
|
|
51
|
+
process.stdin.setRawMode(true);
|
|
52
|
+
process.stdin.resume();
|
|
53
|
+
process.stdin.on("data", onData);
|
|
54
|
+
render();
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
export const input = async (question, fallback) => {
|
|
58
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
59
|
+
const suffix = fallback ? ` (${fallback})` : "";
|
|
60
|
+
return new Promise((resolve) => {
|
|
61
|
+
rl.question(`? ${question}${suffix}: `, (answer) => {
|
|
62
|
+
rl.close();
|
|
63
|
+
resolve(answer.trim() || fallback || "");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
};
|
|
67
|
+
export const password = async (question) => {
|
|
68
|
+
if (!process.stdin.isTTY) {
|
|
69
|
+
throw new Error("Interactive prompt requires a TTY. Pass the value via flag or env var.");
|
|
70
|
+
}
|
|
71
|
+
return new Promise((resolve) => {
|
|
72
|
+
let value = "";
|
|
73
|
+
process.stdout.write(`? ${question}: `);
|
|
74
|
+
const onData = (chunk) => {
|
|
75
|
+
const s = chunk.toString("utf8");
|
|
76
|
+
if (s === "\r" || s === "\n") {
|
|
77
|
+
process.stdin.setRawMode(false);
|
|
78
|
+
process.stdin.pause();
|
|
79
|
+
process.stdin.removeListener("data", onData);
|
|
80
|
+
process.stdout.write("\n");
|
|
81
|
+
resolve(value.trim());
|
|
82
|
+
}
|
|
83
|
+
else if (s === "\u0003") {
|
|
84
|
+
process.stdout.write("\n");
|
|
85
|
+
process.exit(130);
|
|
86
|
+
}
|
|
87
|
+
else if (s === "\b" || s === "\u007f") {
|
|
88
|
+
value = value.slice(0, -1);
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
value += s;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
process.stdin.setRawMode(true);
|
|
95
|
+
process.stdin.resume();
|
|
96
|
+
process.stdin.on("data", onData);
|
|
97
|
+
});
|
|
98
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "sanctum-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Command-line client for the Sanctum secrets platform",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/neang-mengseang/sanctum.git",
|
|
9
|
+
"directory": "packages/cli"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"bin": {
|
|
13
|
+
"sanctum": "dist/cli.js"
|
|
14
|
+
},
|
|
15
|
+
"main": "dist/cli.js",
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"README.md",
|
|
19
|
+
"DEMO.md"
|
|
20
|
+
],
|
|
21
|
+
"prepublishOnly": "npm run build",
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsc",
|
|
24
|
+
"dev": "tsc --watch",
|
|
25
|
+
"typecheck": "tsc --noEmit",
|
|
26
|
+
"clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\""
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"sanctum-sdk": "0.1.0"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/node": "^20.0.0",
|
|
33
|
+
"typescript": "^5.4.0"
|
|
34
|
+
},
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=18"
|
|
37
|
+
}
|
|
38
|
+
}
|