@akshxy/envgit 0.2.0 → 0.4.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/bin/envgit.js CHANGED
@@ -37,9 +37,10 @@ program
37
37
  .action(status);
38
38
 
39
39
  program
40
- .command('set <assignments...>')
41
- .description('Set one or more KEY=VALUE pairs (defaults to active env)')
40
+ .command('set [assignments...]')
41
+ .description('Set KEY=VALUE pairs, or load from a file with -f')
42
42
  .option('--env <name>', 'target environment')
43
+ .option('-f, --file <path>', 'read variables from a .env file')
43
44
  .action(set);
44
45
 
45
46
  program
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akshxy/envgit",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Encrypted per-project environment variable manager",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,10 +1,11 @@
1
1
  import { existsSync, mkdirSync, appendFileSync, readFileSync } from 'fs';
2
2
  import { join } from 'path';
3
+ import { randomUUID } from 'crypto';
3
4
  import { generateKey } from '../crypto.js';
4
- import { saveKey } from '../keystore.js';
5
+ import { saveKey, globalKeyPath } from '../keystore.js';
5
6
  import { saveConfig, getEnvctlDir } from '../config.js';
6
7
  import { writeEncEnv } from '../enc.js';
7
- import { ok, warn, fatal, bold, dim, label } from '../ui.js';
8
+ import { ok, fatal, bold, dim, label } from '../ui.js';
8
9
 
9
10
  export async function init(options) {
10
11
  const projectRoot = process.cwd();
@@ -15,18 +16,21 @@ export async function init(options) {
15
16
  }
16
17
 
17
18
  const defaultEnv = options.env;
19
+ const keyId = randomUUID();
18
20
 
19
21
  mkdirSync(envgitDir, { recursive: true });
20
22
 
21
- const key = generateKey();
22
- saveKey(projectRoot, key);
23
-
23
+ // Save config first (saveKey needs key_id from config)
24
24
  saveConfig(projectRoot, {
25
25
  version: 1,
26
26
  default_env: defaultEnv,
27
27
  envs: [defaultEnv],
28
+ key_id: keyId,
28
29
  });
29
30
 
31
+ const key = generateKey();
32
+ const keyPath = saveKey(projectRoot, key, keyId);
33
+
30
34
  writeEncEnv(projectRoot, defaultEnv, key, {});
31
35
 
32
36
  updateGitignore(projectRoot);
@@ -35,16 +39,19 @@ export async function init(options) {
35
39
  console.log(bold('envgit initialized'));
36
40
  console.log('');
37
41
  ok(`Default environment: ${label(defaultEnv)}`);
38
- ok('Key saved to .envgit.key');
42
+ ok(`Key stored at ${dim(keyPath)}`);
39
43
  console.log('');
40
- console.log(dim('Keep .envgit.key secret and do not commit it.'));
41
44
  console.log(dim('Commit .envgit/ to share encrypted environments with your team.'));
45
+ console.log(dim('Your key never touches the repo — it lives only on your machine.'));
46
+ console.log('');
47
+ console.log(`Share your key with teammates: ${bold('envgit keygen --show')}`);
48
+ console.log(`Teammates save it with: ${bold('envgit keygen --set <key>')}`);
42
49
  console.log('');
43
50
  }
44
51
 
45
52
  function updateGitignore(projectRoot) {
46
53
  const gitignorePath = join(projectRoot, '.gitignore');
47
- const entries = ['.env', '.envgit.key'];
54
+ const entries = ['.env'];
48
55
 
49
56
  let existing = '';
50
57
  if (existsSync(gitignorePath)) {
@@ -1,21 +1,20 @@
1
1
  import { generateKey } from '../crypto.js';
2
- import { findProjectRoot, saveKey, loadKey } from '../keystore.js';
3
- import { ok, warn, bold, dim } from '../ui.js';
2
+ import { findProjectRoot, saveKey, loadKey, globalKeyPath } from '../keystore.js';
3
+ import { loadConfig } from '../config.js';
4
+ import { ok, warn, fatal, bold, dim } from '../ui.js';
4
5
 
5
6
  export async function keygen(options) {
6
7
  const projectRoot = findProjectRoot();
7
8
 
8
9
  if (options.show) {
9
10
  if (!projectRoot) {
10
- warn('No envgit project found — cannot show key.');
11
- process.exit(1);
11
+ fatal('No envgit project found — cannot show key.');
12
12
  }
13
13
  let key;
14
14
  try {
15
15
  key = loadKey(projectRoot);
16
16
  } catch (e) {
17
- warn(e.message);
18
- process.exit(1);
17
+ fatal(e.message);
19
18
  }
20
19
  const hint = key.slice(0, 8);
21
20
  console.log('');
@@ -23,8 +22,8 @@ export async function keygen(options) {
23
22
  console.log(` ${key}`);
24
23
  console.log('');
25
24
  console.log(dim(`Hint (first 8 chars): ${hint}`));
26
- console.log(dim('Share this key with teammates via a secure channel (not git).'));
27
- console.log(dim('They can save it with: envgit keygen --set <key>'));
25
+ console.log(dim('Share via a secure channel (not git, not chat).'));
26
+ console.log(dim('Teammate saves it with: envgit keygen --set <key>'));
28
27
  console.log('');
29
28
  return;
30
29
  }
@@ -33,16 +32,18 @@ export async function keygen(options) {
33
32
  const key = options.set;
34
33
  const decoded = Buffer.from(key, 'base64');
35
34
  if (decoded.length !== 32) {
36
- warn(`Invalid key — must decode to exactly 32 bytes (got ${decoded.length}). Generate one with: envgit keygen`);
37
- process.exit(1);
35
+ fatal(`Invalid key — must decode to exactly 32 bytes (got ${decoded.length}). Generate one with: envgit keygen`);
38
36
  }
39
37
  if (!projectRoot) {
40
- warn('No envgit project found. Run envgit init first.');
41
- process.exit(1);
38
+ fatal('No envgit project found. Run envgit init first, or clone a repo that uses envgit.');
42
39
  }
43
- saveKey(projectRoot, key);
44
- ok(`Key saved to .envgit.key`);
45
- console.log(dim(`Hint: ${key.slice(0, 8)}`));
40
+ const keyPath = saveKey(projectRoot, key);
41
+ ok(`Key saved for this project`);
42
+ console.log(dim(` Stored at: ${keyPath}`));
43
+ console.log(dim(` Hint: ${key.slice(0, 8)}`));
44
+ console.log('');
45
+ console.log(dim('Run `envgit verify` to confirm it works.'));
46
+ console.log('');
46
47
  return;
47
48
  }
48
49
 
@@ -51,8 +52,9 @@ export async function keygen(options) {
51
52
  const hint = key.slice(0, 8);
52
53
 
53
54
  if (projectRoot) {
54
- saveKey(projectRoot, key);
55
- ok('New key generated and saved to .envgit.key');
55
+ const keyPath = saveKey(projectRoot, key);
56
+ ok(`New key generated`);
57
+ console.log(dim(` Stored at: ${keyPath}`));
56
58
  } else {
57
59
  console.log('');
58
60
  console.log(bold('Generated key (no project found — not saved):'));
@@ -63,7 +65,7 @@ export async function keygen(options) {
63
65
  console.log(` ${key}`);
64
66
  console.log('');
65
67
  console.log(dim(`Hint (first 8 chars): ${hint}`));
66
- console.log(dim('Share this key with teammates via a secure channel (not git).'));
67
- console.log(dim('They can save it with: envgit keygen --set <key>'));
68
+ console.log(dim('Share via a secure channel (not git, not chat).'));
69
+ console.log(dim('Teammate saves it with: envgit keygen --set <key>'));
68
70
  console.log('');
69
71
  }
@@ -1,6 +1,9 @@
1
+ import { join } from 'path';
2
+ import { existsSync } from 'fs';
1
3
  import { requireProjectRoot, loadKey } from '../keystore.js';
2
4
  import { resolveEnv } from '../config.js';
3
5
  import { readEncEnv, writeEncEnv } from '../enc.js';
6
+ import { readEnvFile } from '../envfile.js';
4
7
  import { getCurrentEnv } from '../state.js';
5
8
  import { ok, fatal, label } from '../ui.js';
6
9
 
@@ -11,15 +14,31 @@ export async function set(assignments, options) {
11
14
 
12
15
  const vars = readEncEnv(projectRoot, envName, key);
13
16
 
14
- for (const assignment of assignments) {
15
- const eqIdx = assignment.indexOf('=');
16
- if (eqIdx === -1) {
17
- fatal(`Invalid assignment '${assignment}'. Expected KEY=VALUE format.`);
17
+ if (options.file) {
18
+ const filePath = join(projectRoot, options.file);
19
+ if (!existsSync(filePath)) {
20
+ fatal(`File not found: ${options.file}`);
21
+ }
22
+ const fileVars = readEnvFile(filePath);
23
+ const entries = Object.entries(fileVars);
24
+ if (entries.length === 0) {
25
+ fatal(`No variables found in ${options.file}`);
26
+ }
27
+ for (const [k, v] of entries) {
28
+ vars[k] = v;
29
+ ok(`Set ${k} in ${label(envName)}`);
30
+ }
31
+ } else {
32
+ for (const assignment of assignments) {
33
+ const eqIdx = assignment.indexOf('=');
34
+ if (eqIdx === -1) {
35
+ fatal(`Invalid assignment '${assignment}'. Expected KEY=VALUE format.`);
36
+ }
37
+ const k = assignment.slice(0, eqIdx).trim();
38
+ const v = assignment.slice(eqIdx + 1);
39
+ vars[k] = v;
40
+ ok(`Set ${k} in ${label(envName)}`);
18
41
  }
19
- const k = assignment.slice(0, eqIdx).trim();
20
- const v = assignment.slice(eqIdx + 1);
21
- vars[k] = v;
22
- ok(`Set ${k} in ${label(envName)}`);
23
42
  }
24
43
 
25
44
  writeEncEnv(projectRoot, envName, key, vars);
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from 'fs';
2
2
  import { join } from 'path';
3
- import { requireProjectRoot } from '../keystore.js';
3
+ import { requireProjectRoot, globalKeyPath } from '../keystore.js';
4
4
  import { loadConfig } from '../config.js';
5
5
  import { getCurrentEnv } from '../state.js';
6
6
  import chalk from 'chalk';
@@ -11,11 +11,19 @@ export async function status() {
11
11
  const config = loadConfig(projectRoot);
12
12
  const current = getCurrentEnv(projectRoot);
13
13
 
14
- const keySource = process.env.ENVCTL_KEY
15
- ? 'ENVCTL_KEY env var'
16
- : existsSync(join(projectRoot, '.envgit.key'))
17
- ? '.envgit.key'
18
- : chalk.red('(not found)');
14
+ let keySource;
15
+ if (process.env.ENVGIT_KEY) {
16
+ keySource = chalk.green('ENVGIT_KEY') + dim(' (env var)');
17
+ } else if (config.key_id) {
18
+ const keyPath = globalKeyPath(config.key_id);
19
+ keySource = existsSync(keyPath)
20
+ ? chalk.green('~/.config/envgit/keys/') + dim(config.key_id.slice(0, 8) + '…')
21
+ : chalk.red('not found on this machine') + dim(' — run: envgit keygen --set <key>');
22
+ } else {
23
+ // Legacy
24
+ const legacyPath = join(projectRoot, '.envgit.key');
25
+ keySource = existsSync(legacyPath) ? dim('.envgit.key (legacy)') : chalk.red('(not found)');
26
+ }
19
27
 
20
28
  const dotenvExists = existsSync(join(projectRoot, '.env'));
21
29
 
package/src/envfile.js CHANGED
@@ -1,5 +1,338 @@
1
1
  import { readFileSync, writeFileSync, existsSync } from 'fs';
2
2
 
3
+ // ─── Section label mappings (prefix → section name) ──────────────────────────
4
+ const SECTION_LABELS = {
5
+ // ── App / Runtime ──────────────────────────────────────────────────────────
6
+ NODE: 'App / Runtime',
7
+ APP: 'App / Config',
8
+ SERVER: 'App / Server',
9
+ SERVICE: 'App / Service',
10
+ WORKER: 'App / Worker',
11
+
12
+ // ── Frontend Frameworks ────────────────────────────────────────────────────
13
+ NEXT: 'Next.js',
14
+ NEXT_PUBLIC: 'Next.js / Public (client-side)',
15
+ NUXT: 'Nuxt',
16
+ NUXT_PUBLIC: 'Nuxt / Public (client-side)',
17
+ VITE: 'Vite',
18
+ VITE_APP: 'Vite / Public (client-side)',
19
+ REACT: 'React',
20
+ REACT_APP: 'React / Public (client-side)',
21
+ GATSBY: 'Gatsby',
22
+ SVELTE: 'SvelteKit',
23
+ PUBLIC: 'Public (client-side)',
24
+
25
+ // ── Databases ──────────────────────────────────────────────────────────────
26
+ DB: 'Database',
27
+ DATABASE: 'Database',
28
+ POSTGRES: 'Database / PostgreSQL',
29
+ POSTGRESQL: 'Database / PostgreSQL',
30
+ PG: 'Database / PostgreSQL',
31
+ PGHOST: 'Database / PostgreSQL',
32
+ PGPORT: 'Database / PostgreSQL',
33
+ PGUSER: 'Database / PostgreSQL',
34
+ PGPASSWORD: 'Database / PostgreSQL',
35
+ PGDATABASE: 'Database / PostgreSQL',
36
+ MYSQL: 'Database / MySQL',
37
+ MARIADB: 'Database / MariaDB',
38
+ MONGO: 'Database / MongoDB',
39
+ MONGODB: 'Database / MongoDB',
40
+ SQLITE: 'Database / SQLite',
41
+ COCKROACH: 'Database / CockroachDB',
42
+ COCKROACHDB: 'Database / CockroachDB',
43
+ NEON: 'Database / Neon',
44
+ PLANETSCALE: 'Database / PlanetScale',
45
+ TURSO: 'Database / Turso',
46
+ SUPABASE: 'Supabase',
47
+ XATA: 'Database / Xata',
48
+ CONVEX: 'Database / Convex',
49
+
50
+ // ── Cache / Queues ─────────────────────────────────────────────────────────
51
+ REDIS: 'Cache / Redis',
52
+ UPSTASH: 'Cache / Upstash Redis',
53
+ MEMCACHED: 'Cache / Memcached',
54
+ KAFKA: 'Queue / Kafka',
55
+ RABBITMQ: 'Queue / RabbitMQ',
56
+ SQS: 'Queue / AWS SQS',
57
+ BULL: 'Queue / Bull',
58
+ INNGEST: 'Queue / Inngest',
59
+ TRIGGER: 'Queue / Trigger.dev',
60
+ QSTASH: 'Queue / QStash',
61
+
62
+ // ── Auth ───────────────────────────────────────────────────────────────────
63
+ AUTH: 'Auth',
64
+ AUTH0: 'Auth / Auth0',
65
+ CLERK: 'Auth / Clerk',
66
+ NEXTAUTH: 'Auth / NextAuth',
67
+ NEXT_AUTH: 'Auth / NextAuth',
68
+ LUCIA: 'Auth / Lucia',
69
+ BETTER_AUTH: 'Auth / Better Auth',
70
+ SUPABASE_AUTH: 'Auth / Supabase Auth',
71
+ FIREBASE_AUTH: 'Auth / Firebase Auth',
72
+ COGNITO: 'Auth / AWS Cognito',
73
+ OKTA: 'Auth / Okta',
74
+ WORKOS: 'Auth / WorkOS',
75
+ PROPELAUTH: 'Auth / PropelAuth',
76
+ STYTCH: 'Auth / Stytch',
77
+ MAGIC: 'Auth / Magic',
78
+ OAUTH: 'Auth / OAuth',
79
+ JWT: 'Auth / JWT',
80
+ SESSION: 'Auth / Session',
81
+ COOKIE: 'Auth / Cookie',
82
+ CSRF: 'Auth / CSRF',
83
+ TOTP: 'Auth / 2FA',
84
+ MFA: 'Auth / 2FA',
85
+
86
+ // ── AWS ────────────────────────────────────────────────────────────────────
87
+ AWS: 'AWS',
88
+ S3: 'AWS / S3',
89
+ EC2: 'AWS / EC2',
90
+ ECS: 'AWS / ECS',
91
+ EKS: 'AWS / EKS',
92
+ LAMBDA: 'AWS / Lambda',
93
+ CLOUDFRONT: 'AWS / CloudFront',
94
+ CLOUDWATCH: 'AWS / CloudWatch',
95
+ COGNITO: 'AWS / Cognito',
96
+ DYNAMODB: 'AWS / DynamoDB',
97
+ SES: 'AWS / SES',
98
+ SNS: 'AWS / SNS',
99
+ SQS: 'AWS / SQS',
100
+ ROUTE53: 'AWS / Route 53',
101
+ ECR: 'AWS / ECR',
102
+ SECRETS: 'AWS / Secrets Manager',
103
+
104
+ // ── Google Cloud ───────────────────────────────────────────────────────────
105
+ GCP: 'Google Cloud',
106
+ GOOGLE: 'Google',
107
+ GOOGLE_CLOUD: 'Google Cloud',
108
+ FIREBASE: 'Firebase',
109
+ FIRESTORE: 'Firebase / Firestore',
110
+ GCS: 'Google Cloud / Storage',
111
+ BIGQUERY: 'Google Cloud / BigQuery',
112
+ PUBSUB: 'Google Cloud / Pub/Sub',
113
+ GCLOUD: 'Google Cloud',
114
+
115
+ // ── Azure ──────────────────────────────────────────────────────────────────
116
+ AZURE: 'Azure',
117
+ AZURE_AD: 'Azure / Active Directory',
118
+ COSMOS: 'Azure / Cosmos DB',
119
+ COSMOSDB: 'Azure / Cosmos DB',
120
+ BLOB: 'Azure / Blob Storage',
121
+
122
+ // ── AI / LLMs ──────────────────────────────────────────────────────────────
123
+ OPENAI: 'AI / OpenAI',
124
+ ANTHROPIC: 'AI / Anthropic',
125
+ GEMINI: 'AI / Google Gemini',
126
+ COHERE: 'AI / Cohere',
127
+ REPLICATE: 'AI / Replicate',
128
+ HUGGINGFACE: 'AI / HuggingFace',
129
+ HF: 'AI / HuggingFace',
130
+ TOGETHER: 'AI / Together AI',
131
+ GROQ: 'AI / Groq',
132
+ MISTRAL: 'AI / Mistral',
133
+ PERPLEXITY: 'AI / Perplexity',
134
+ FIREWORKS: 'AI / Fireworks AI',
135
+ ANYSCALE: 'AI / Anyscale',
136
+ AI21: 'AI / AI21 Labs',
137
+ STABILITY: 'AI / Stability AI',
138
+ DEEPINFRA: 'AI / DeepInfra',
139
+ ELEVENLABS: 'AI / ElevenLabs',
140
+ ASSEMBLYAI: 'AI / AssemblyAI',
141
+ DEEPGRAM: 'AI / Deepgram',
142
+
143
+ // ── Vector DBs ─────────────────────────────────────────────────────────────
144
+ PINECONE: 'Vector DB / Pinecone',
145
+ WEAVIATE: 'Vector DB / Weaviate',
146
+ QDRANT: 'Vector DB / Qdrant',
147
+ CHROMA: 'Vector DB / Chroma',
148
+ MILVUS: 'Vector DB / Milvus',
149
+
150
+ // ── Payments ───────────────────────────────────────────────────────────────
151
+ STRIPE: 'Payments / Stripe',
152
+ PAYPAL: 'Payments / PayPal',
153
+ BRAINTREE: 'Payments / Braintree',
154
+ SQUARE: 'Payments / Square',
155
+ LEMON: 'Payments / Lemon Squeezy',
156
+ LEMONSQUEEZY: 'Payments / Lemon Squeezy',
157
+ PADDLE: 'Payments / Paddle',
158
+ COINBASE: 'Payments / Coinbase Commerce',
159
+ RAZORPAY: 'Payments / Razorpay',
160
+
161
+ // ── Email ──────────────────────────────────────────────────────────────────
162
+ SMTP: 'Email / SMTP',
163
+ MAIL: 'Email',
164
+ EMAIL: 'Email',
165
+ SENDGRID: 'Email / SendGrid',
166
+ RESEND: 'Email / Resend',
167
+ MAILGUN: 'Email / Mailgun',
168
+ POSTMARK: 'Email / Postmark',
169
+ MAILCHIMP: 'Email / Mailchimp',
170
+ MANDRILL: 'Email / Mandrill',
171
+ SES: 'Email / AWS SES',
172
+ SPARKPOST: 'Email / SparkPost',
173
+ CONVERTKIT: 'Email / ConvertKit',
174
+ LOOPS: 'Email / Loops',
175
+
176
+ // ── SMS / Communications ───────────────────────────────────────────────────
177
+ TWILIO: 'SMS / Twilio',
178
+ VONAGE: 'SMS / Vonage',
179
+ MESSAGEBIRD: 'SMS / MessageBird',
180
+ TELNYX: 'SMS / Telnyx',
181
+ SINCH: 'SMS / Sinch',
182
+ BANDWIDTH: 'SMS / Bandwidth',
183
+
184
+ // ── Search ─────────────────────────────────────────────────────────────────
185
+ ALGOLIA: 'Search / Algolia',
186
+ MEILISEARCH: 'Search / Meilisearch',
187
+ TYPESENSE: 'Search / Typesense',
188
+ ELASTICSEARCH: 'Search / Elasticsearch',
189
+ OPENSEARCH: 'Search / OpenSearch',
190
+
191
+ // ── Storage / CDN ──────────────────────────────────────────────────────────
192
+ CLOUDINARY: 'Storage / Cloudinary',
193
+ IMAGEKIT: 'Storage / ImageKit',
194
+ UPLOADTHING: 'Storage / UploadThing',
195
+ BUNNY: 'Storage / Bunny CDN',
196
+ BACKBLAZE: 'Storage / Backblaze B2',
197
+ R2: 'Storage / Cloudflare R2',
198
+ TIGRIS: 'Storage / Tigris',
199
+
200
+ // ── Observability ──────────────────────────────────────────────────────────
201
+ SENTRY: 'Observability / Sentry',
202
+ DATADOG: 'Observability / Datadog',
203
+ NEWRELIC: 'Observability / New Relic',
204
+ NEW_RELIC: 'Observability / New Relic',
205
+ GRAFANA: 'Observability / Grafana',
206
+ PROMETHEUS: 'Observability / Prometheus',
207
+ LOGTAIL: 'Observability / Logtail',
208
+ AXIOM: 'Observability / Axiom',
209
+ BETTERSTACK: 'Observability / Better Stack',
210
+ LOGFLARE: 'Observability / Logflare',
211
+ HIGHLIGHT: 'Observability / Highlight',
212
+ BASELIME: 'Observability / Baselime',
213
+
214
+ // ── Analytics ──────────────────────────────────────────────────────────────
215
+ POSTHOG: 'Analytics / PostHog',
216
+ SEGMENT: 'Analytics / Segment',
217
+ MIXPANEL: 'Analytics / Mixpanel',
218
+ AMPLITUDE: 'Analytics / Amplitude',
219
+ HEAP: 'Analytics / Heap',
220
+ HOTJAR: 'Analytics / Hotjar',
221
+ GA: 'Analytics / Google Analytics',
222
+ GOOGLE_ANALYTICS: 'Analytics / Google Analytics',
223
+ PLAUSIBLE: 'Analytics / Plausible',
224
+ FATHOM: 'Analytics / Fathom',
225
+ PIRSCH: 'Analytics / Pirsch',
226
+ UMAMI: 'Analytics / Umami',
227
+ JUNE: 'Analytics / June',
228
+ OPENPANEL: 'Analytics / OpenPanel',
229
+
230
+ // ── Feature Flags ──────────────────────────────────────────────────────────
231
+ LAUNCHDARKLY: 'Feature Flags / LaunchDarkly',
232
+ GROWTHBOOK: 'Feature Flags / GrowthBook',
233
+ FLAGSMITH: 'Feature Flags / Flagsmith',
234
+ STATSIG: 'Feature Flags / Statsig',
235
+ UNLEASH: 'Feature Flags / Unleash',
236
+ HYPERTUNE: 'Feature Flags / Hypertune',
237
+
238
+ // ── CMS ────────────────────────────────────────────────────────────────────
239
+ CONTENTFUL: 'CMS / Contentful',
240
+ SANITY: 'CMS / Sanity',
241
+ STRAPI: 'CMS / Strapi',
242
+ DIRECTUS: 'CMS / Directus',
243
+ PAYLOAD: 'CMS / Payload',
244
+ PRISMIC: 'CMS / Prismic',
245
+ GHOST: 'CMS / Ghost',
246
+ STORYBLOK: 'CMS / Storyblok',
247
+ BUILDER: 'CMS / Builder.io',
248
+
249
+ // ── Hosting / Deploy ───────────────────────────────────────────────────────
250
+ VERCEL: 'Hosting / Vercel',
251
+ NETLIFY: 'Hosting / Netlify',
252
+ RAILWAY: 'Hosting / Railway',
253
+ RENDER: 'Hosting / Render',
254
+ FLY: 'Hosting / Fly.io',
255
+ COOLIFY: 'Hosting / Coolify',
256
+ DOKKU: 'Hosting / Dokku',
257
+ HEROKU: 'Hosting / Heroku',
258
+ DENO: 'Hosting / Deno Deploy',
259
+ CLOUDFLARE: 'Hosting / Cloudflare',
260
+
261
+ // ── Social / OAuth Providers ───────────────────────────────────────────────
262
+ GITHUB: 'OAuth / GitHub',
263
+ GITLAB: 'OAuth / GitLab',
264
+ TWITTER: 'OAuth / Twitter / X',
265
+ TWITTER_X: 'OAuth / Twitter / X',
266
+ X: 'OAuth / Twitter / X',
267
+ FACEBOOK: 'OAuth / Facebook',
268
+ INSTAGRAM: 'OAuth / Instagram',
269
+ LINKEDIN: 'OAuth / LinkedIn',
270
+ DISCORD: 'OAuth / Discord',
271
+ SPOTIFY: 'OAuth / Spotify',
272
+ APPLE: 'OAuth / Apple',
273
+ MICROSOFT: 'OAuth / Microsoft',
274
+
275
+ // ── Collaboration / Productivity ───────────────────────────────────────────
276
+ SLACK: 'Integrations / Slack',
277
+ NOTION: 'Integrations / Notion',
278
+ LINEAR: 'Integrations / Linear',
279
+ JIRA: 'Integrations / Jira',
280
+ AIRTABLE: 'Integrations / Airtable',
281
+ ZAPIER: 'Integrations / Zapier',
282
+ MAKE: 'Integrations / Make',
283
+
284
+ // ── Maps / Location ────────────────────────────────────────────────────────
285
+ MAPBOX: 'Maps / Mapbox',
286
+ GOOGLE_MAPS: 'Maps / Google Maps',
287
+ MAPS: 'Maps / Google Maps',
288
+ HERE: 'Maps / HERE',
289
+ IPINFO: 'Maps / IPinfo',
290
+ MAXMIND: 'Maps / MaxMind',
291
+
292
+ // ── Crypto / Web3 ──────────────────────────────────────────────────────────
293
+ ALCHEMY: 'Web3 / Alchemy',
294
+ INFURA: 'Web3 / Infura',
295
+ MORALIS: 'Web3 / Moralis',
296
+ THIRDWEB: 'Web3 / Thirdweb',
297
+ WALLET: 'Web3 / Wallet',
298
+
299
+ // ── Testing / Dev Tools ────────────────────────────────────────────────────
300
+ PLAYWRIGHT: 'Testing / Playwright',
301
+ CYPRESS: 'Testing / Cypress',
302
+ BROWSERSTACK: 'Testing / BrowserStack',
303
+ SAUCE: 'Testing / Sauce Labs',
304
+ STORYBOOK: 'Dev / Storybook',
305
+ CHROMATIC: 'Dev / Chromatic',
306
+ };
307
+
308
+ const STANDALONE_LABELS = {
309
+ PORT: 'App / Config',
310
+ HOST: 'App / Config',
311
+ NODE_ENV: 'App / Config',
312
+ APP_ENV: 'App / Config',
313
+ ENVIRONMENT: 'App / Config',
314
+ BASE_URL: 'App / Config',
315
+ API_URL: 'App / Config',
316
+ SITE_URL: 'App / Config',
317
+ FRONTEND_URL: 'App / Config',
318
+ BACKEND_URL: 'App / Config',
319
+ PUBLIC_URL: 'App / Config',
320
+ LOG_LEVEL: 'App / Config',
321
+ DEBUG: 'App / Config',
322
+ TZ: 'App / Config',
323
+ TIMEZONE: 'App / Config',
324
+ LOCALE: 'App / Config',
325
+ LANG: 'App / Config',
326
+ SECRET: 'Secrets',
327
+ SECRET_KEY: 'Secrets',
328
+ ENCRYPTION_KEY: 'Secrets',
329
+ API_KEY: 'API Keys',
330
+ API_SECRET: 'API Keys',
331
+ ACCESS_TOKEN: 'API Keys',
332
+ PRIVATE_KEY: 'Secrets',
333
+ PUBLIC_KEY: 'Secrets',
334
+ };
335
+
3
336
  export function parseEnv(content) {
4
337
  const vars = {};
5
338
  for (const line of content.split('\n')) {
@@ -9,7 +342,6 @@ export function parseEnv(content) {
9
342
  if (eqIdx === -1) continue;
10
343
  const key = trimmed.slice(0, eqIdx).trim();
11
344
  let value = trimmed.slice(eqIdx + 1).trim();
12
- // Strip surrounding quotes
13
345
  if (
14
346
  (value.startsWith('"') && value.endsWith('"')) ||
15
347
  (value.startsWith("'") && value.endsWith("'"))
@@ -21,6 +353,7 @@ export function parseEnv(content) {
21
353
  return vars;
22
354
  }
23
355
 
356
+ // Used internally for encryption — keeps insertion order, no formatting
24
357
  export function stringifyEnv(vars) {
25
358
  const lines = Object.entries(vars).map(([k, v]) => {
26
359
  const needsQuotes = /[\s"'\\#]/.test(v) || v === '';
@@ -30,19 +363,95 @@ export function stringifyEnv(vars) {
30
363
  return lines.join('\n') + (lines.length ? '\n' : '');
31
364
  }
32
365
 
366
+ function formatValue(v) {
367
+ const needsQuotes = /[\s"'\\#]/.test(v) || v === '';
368
+ const escaped = v.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
369
+ return needsQuotes ? `"${escaped}"` : v;
370
+ }
371
+
372
+ function getSectionForKey(key) {
373
+ if (STANDALONE_LABELS[key]) return STANDALONE_LABELS[key];
374
+
375
+ // Try progressively shorter prefixes — longest match wins
376
+ const parts = key.split('_');
377
+ for (let len = parts.length - 1; len >= 1; len--) {
378
+ const prefix = parts.slice(0, len).join('_');
379
+ if (SECTION_LABELS[prefix]) return SECTION_LABELS[prefix];
380
+ }
381
+
382
+ return 'General';
383
+ }
384
+
385
+ function groupAndSort(vars) {
386
+ const sections = {};
387
+
388
+ for (const [key, value] of Object.entries(vars)) {
389
+ const section = getSectionForKey(key);
390
+ if (!sections[section]) sections[section] = [];
391
+ sections[section].push([key, value]);
392
+ }
393
+
394
+ for (const section of Object.keys(sections)) {
395
+ sections[section].sort(([a], [b]) => a.localeCompare(b));
396
+ }
397
+
398
+ // App/Config first, Secrets near top, General at bottom
399
+ const priority = {
400
+ 'App / Config': 0,
401
+ 'App / Runtime': 1,
402
+ 'App / Server': 2,
403
+ 'Secrets': 3,
404
+ 'API Keys': 4,
405
+ 'General': 999,
406
+ };
407
+
408
+ return Object.entries(sections).sort(([a], [b]) => {
409
+ const pa = priority[a] ?? 50;
410
+ const pb = priority[b] ?? 50;
411
+ if (pa !== pb) return pa - pb;
412
+ return a.localeCompare(b);
413
+ });
414
+ }
415
+
33
416
  export function readEnvFile(filePath) {
34
417
  if (!existsSync(filePath)) return {};
35
418
  return parseEnv(readFileSync(filePath, 'utf8'));
36
419
  }
37
420
 
38
421
  export function writeEnvFile(filePath, vars, { envName, projectRoot } = {}) {
39
- let content = '';
422
+ const entries = Object.entries(vars);
423
+ const projectName = projectRoot ? projectRoot.split('/').pop() : null;
424
+ const lines = [];
425
+
426
+ // Header
40
427
  if (envName) {
41
- const projectName = projectRoot ? projectRoot.split('/').pop() : null;
42
- content += `# Generated by envgit from [${envName}]`;
43
- if (projectName) content += ` (${projectName})`;
44
- content += '\n# Do not edit directly use envgit set to update values\n\n';
428
+ const width = 50;
429
+ const bar = '─'.repeat(width);
430
+ lines.push(`# ${bar}`);
431
+ lines.push(`# envgitencrypted environment manager`);
432
+ if (projectName) lines.push(`# Project : ${projectName}`);
433
+ lines.push(`# Env : ${envName}`);
434
+ lines.push(`# Edit : envgit set KEY=VALUE --env ${envName}`);
435
+ lines.push(`# ${bar}`);
436
+ lines.push('');
437
+ }
438
+
439
+ if (entries.length === 0) {
440
+ writeFileSync(filePath, lines.join('\n'), 'utf8');
441
+ return;
442
+ }
443
+
444
+ const grouped = groupAndSort(vars);
445
+ const maxKeyLen = Math.max(...entries.map(([k]) => k.length));
446
+
447
+ for (const [section, sectionVars] of grouped) {
448
+ lines.push(`# ── ${section} ${'─'.repeat(Math.max(0, 44 - section.length))}`);
449
+ for (const [k, v] of sectionVars) {
450
+ const padding = ' '.repeat(maxKeyLen - k.length);
451
+ lines.push(`${k}${padding} = ${formatValue(v)}`);
452
+ }
453
+ lines.push('');
45
454
  }
46
- content += stringifyEnv(vars);
47
- writeFileSync(filePath, content, 'utf8');
455
+
456
+ writeFileSync(filePath, lines.join('\n'), 'utf8');
48
457
  }
package/src/keystore.js CHANGED
@@ -1,16 +1,25 @@
1
- import { readFileSync, writeFileSync, chmodSync, existsSync, statSync } from 'fs';
1
+ import { readFileSync, writeFileSync, chmodSync, existsSync, statSync, mkdirSync } from 'fs';
2
2
  import { join, dirname } from 'path';
3
+ import { homedir } from 'os';
4
+ import { loadConfig } from './config.js';
3
5
 
4
- const KEY_FILE = '.envgit.key';
5
6
  const ENV_VAR = 'ENVGIT_KEY';
6
7
  const SECURE_MODE = 0o600;
7
8
 
9
+ function globalKeysDir() {
10
+ return join(homedir(), '.config', 'envgit', 'keys');
11
+ }
12
+
13
+ export function globalKeyPath(keyId) {
14
+ return join(globalKeysDir(), `${keyId}.key`);
15
+ }
16
+
8
17
  export function findProjectRoot(startDir = process.cwd()) {
9
18
  let dir = startDir;
10
19
  while (true) {
11
20
  if (existsSync(join(dir, '.envgit'))) return dir;
12
21
  const parent = dirname(dir);
13
- if (parent === dir) return null; // reached filesystem root
22
+ if (parent === dir) return null;
14
23
  dir = parent;
15
24
  }
16
25
  }
@@ -26,27 +35,53 @@ export function requireProjectRoot() {
26
35
 
27
36
  export function loadKey(projectRoot) {
28
37
  if (process.env[ENV_VAR]) return process.env[ENV_VAR];
29
- const keyPath = join(projectRoot, KEY_FILE);
30
- if (existsSync(keyPath)) {
31
- const stat = statSync(keyPath);
32
- const mode = stat.mode & 0o777;
33
- if (mode & 0o077) {
34
- console.error(
35
- `Error: ${KEY_FILE} has insecure permissions (${mode.toString(8)}). Run: chmod 600 ${KEY_FILE}`
36
- );
37
- process.exit(1);
38
+
39
+ const config = loadConfig(projectRoot);
40
+
41
+ if (config.key_id) {
42
+ const keyPath = globalKeyPath(config.key_id);
43
+ if (existsSync(keyPath)) {
44
+ enforcePermissions(keyPath);
45
+ return readFileSync(keyPath, 'utf8').trim();
38
46
  }
39
- return readFileSync(keyPath, 'utf8').trim();
47
+ // Project found, but this machine doesn't have the key yet
48
+ console.error(
49
+ `No key found for this project. Ask your team for the key, then run:\n envgit keygen --set <key>`
50
+ );
51
+ process.exit(1);
40
52
  }
41
- throw new Error(
42
- `No encryption key found. Add it to ${KEY_FILE} or set the ENVGIT_KEY environment variable.`
53
+
54
+ // Legacy fallback: .envgit.key in project root
55
+ const legacyPath = join(projectRoot, '.envgit.key');
56
+ if (existsSync(legacyPath)) {
57
+ enforcePermissions(legacyPath);
58
+ return readFileSync(legacyPath, 'utf8').trim();
59
+ }
60
+
61
+ console.error(
62
+ `No key found for this project. Ask your team for the key, then run:\n envgit keygen --set <key>`
43
63
  );
64
+ process.exit(1);
44
65
  }
45
66
 
46
- export function saveKey(projectRoot, key) {
47
- const keyPath = join(projectRoot, KEY_FILE);
67
+ export function saveKey(projectRoot, key, keyId) {
68
+ const dir = globalKeysDir();
69
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
70
+
71
+ const id = keyId ?? loadConfig(projectRoot).key_id;
72
+ const keyPath = globalKeyPath(id);
48
73
  writeFileSync(keyPath, key + '\n', { mode: SECURE_MODE });
49
- // Explicitly tighten permissions in case the file already existed —
50
- // writeFileSync's mode option only applies when creating a new file.
51
74
  chmodSync(keyPath, SECURE_MODE);
75
+ return keyPath;
76
+ }
77
+
78
+ function enforcePermissions(keyPath) {
79
+ const stat = statSync(keyPath);
80
+ const mode = stat.mode & 0o777;
81
+ if (mode & 0o077) {
82
+ console.error(
83
+ `Error: key file has insecure permissions (${mode.toString(8)}). Run: chmod 600 ${keyPath}`
84
+ );
85
+ process.exit(1);
86
+ }
52
87
  }