10 Commits
0.1.3 ... main

Author SHA1 Message Date
Morten Olsen
24e8726e41 fix: incorrect pnpm version in github action 2025-12-10 12:34:50 +01:00
Morten Olsen
efa489d524 chore: update dependencies to avoid vulnerabilities 2025-12-09 13:10:17 +01:00
Morten Olsen
1321ea4748 fix: corrected profile reading and default file order 2025-12-09 13:08:31 +01:00
Morten Olsen
70f1cf9134 chore: updated dependencies 2025-10-03 14:59:39 +02:00
Morten Olsen
815ac86873 feat: support env expansion 2025-10-02 15:47:44 +02:00
Morten Olsen
14c4d5c386 fix: support more than 10 SSM parameters 2025-09-10 14:09:09 +02:00
Morten Olsen
00786d5508 chore: minor QoL improvements 2025-08-08 20:22:10 +02:00
Morten Olsen
ef32edcb29 chore: bump AWS client version 2025-08-08 20:13:54 +02:00
Morten Olsen
e2dfb3491d feat: use bundled version 2025-08-08 20:11:04 +02:00
Morten Olsen
3ec6612167 docs: add compare section 2025-08-08 16:34:58 +02:00
14 changed files with 1562 additions and 1294 deletions

View File

@@ -1 +0,0 @@
PASSWORD=SSM:/test/hfd/rds/DB_USER

View File

@@ -12,13 +12,13 @@ on:
env:
environment: test
release_channel: latest
DO_NOT_TRACK: '1'
NODE_VERSION: '23.x'
NODE_REGISTRY: 'https://registry.npmjs.org'
DO_NOT_TRACK: "1"
NODE_VERSION: "23.x"
NODE_REGISTRY: "https://registry.npmjs.org"
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
DOCKER_REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
PNPM_VERSION: 10.6.0
PNPM_VERSION: 10.17.0
permissions:
contents: write
@@ -56,8 +56,8 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '${{ env.NODE_VERSION }}'
registry-url: '${{ env.NODE_REGISTRY }}'
node-version: "${{ env.NODE_VERSION }}"
registry-url: "${{ env.NODE_REGISTRY }}"
- uses: pnpm/action-setup@v4
name: Install pnpm
@@ -82,4 +82,5 @@ jobs:
node ./scripts/set-version.mjs $(git describe --tag --abbrev=0)
pnpm publish -r --no-git-checks --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

0
.npmrc Normal file
View File

View File

@@ -25,7 +25,7 @@ that get resolved at runtime.
## Installation
```bash
npm install -g @0morten-olsen/with-ssm
npm install -g @morten-olsen/with-ssm
```
## Quick Start
@@ -117,7 +117,7 @@ override the SSM-resolved values. To avoid this:
- Use `.env.with-ssm` instead of `.env` for SSM references
- Or use environment variable substitution if your app supports it:
`${API_KEY:-SSM:/myapp/api-key}`
`API_KEY=${API_KEY:-SSM:/myapp/api-key}`
### 🚀 Deployment Considerations
@@ -173,3 +173,50 @@ with-ssm -- docker-compose up
# Deploy with production secrets
with-ssm --profile production -- npm run deploy
```
## How does `with-ssm` compare to...?
`with-ssm`'s philosophy is to be a lightweight utility that enhances your
existing workflow, not a heavy framework that replaces it. Heres how it
compares to other tools.
### vs. `.env` files & `dotenv`
`with-ssm` is a security upgrade for the `dotenv` pattern. Instead of storing
secrets in plaintext `.env` files, you store secure `SSM:` references that are
safe to commit to version control. Your app gets the secrets it needs at
runtime, but they never live on your disk, giving you the same simple developer
experience with a major security boost.
### vs. `aws-vault`
These tools are complementary and solve different problems. `aws-vault` securely
manages your local AWS _credentials_, while `with-ssm` uses those credentials to
fetch and inject application _secrets_. They work perfectly together—use
`aws-vault` to handle authentication and `with-ssm` to handle secret resolution:
`aws-vault exec my-profile -- with-ssm -- npm start`.
### vs. `chamber`
`chamber` is a more powerful CLI for the full lifecycle of secret management
(reading, writing, listing), while `with-ssm` is a lightweight utility focused
only on resolving secret references from a file. Choose `with-ssm` for its
"drop-in" simplicity and zero-config approach to enhance an existing `.env`
workflow; choose `chamber` if you need a more comprehensive command-line tool
for advanced SSM tasks.
### vs. Cloud-Native Integrations (ECS, Lambda)
`with-ssm` is built for **local development and CI/CD**, allowing your local
environment to securely mirror production. When your code is running _inside_ an
AWS environment like ECS or Lambda, you should always use the native best
practice: grant the service an IAM role to fetch secrets directly via the AWS
SDK.
### vs. Full Secret Management Platforms (HashiCorp Vault, Doppler)
Platforms like Vault or Doppler are comprehensive, often multi-cloud solutions
with their own UIs and infrastructure. `with-ssm` is a focused, AWS-native
utility, not a platform. It's the ideal choice for teams already using AWS who
want a simple, direct way to leverage SSM Parameter Store without the
operational overhead of a separate service.

View File

@@ -1,2 +1,2 @@
#!/usr/bin/env node
import '../dist/start.js';
import '../dist/index.js';

View File

@@ -6,37 +6,37 @@
"license": "GPL-3.0",
"scripts": {
"test:lint": "eslint",
"build": "tsc --build",
"build:dev": "tsc --build --watch",
"build": "ncc build src/start.ts -s -o dist",
"build:dev": "ncc build src/start.ts -s -o dist --watch",
"test:unit": "vitest --run --passWithNoTests",
"test": "pnpm run \"/^test:/\""
},
"packageManager": "pnpm@10.6.0",
"packageManager": "pnpm@10.17.0",
"files": [
"dist"
],
"devDependencies": {
"@aws-sdk/client-ssm": "^3.947.0",
"@aws-sdk/client-sts": "^3.947.0",
"@dotenvx/dotenvx": "^1.51.1",
"@eslint/eslintrc": "3.3.1",
"@eslint/js": "9.32.0",
"@eslint/js": "9.36.0",
"@pnpm/find-workspace-packages": "6.0.9",
"@types/node": "24.2.0",
"@types/yargs": "^17.0.33",
"@types/node": "24.6.2",
"@types/yargs": "^17.0.35",
"@vercel/ncc": "^0.38.4",
"@vitest/coverage-v8": "3.2.4",
"eslint": "9.32.0",
"eslint": "9.36.0",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-prettier": "5.5.4",
"execa": "^9.6.1",
"prettier": "3.6.2",
"typescript": "5.9.2",
"typescript-eslint": "8.39.0",
"vitest": "3.2.4"
"typescript": "5.9.3",
"typescript-eslint": "8.45.0",
"vitest": "3.2.4",
"yargs": "^18.0.0"
},
"name": "@morten-olsen/with-ssm",
"version": "1.0.0",
"dependencies": {
"@aws-sdk/client-ssm": "^3.859.0",
"dotenv": "^17.2.1",
"execa": "^9.6.0",
"yargs": "^18.0.0"
}
}
"version": "1.0.0"
}

2654
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

2
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,2 @@
onlyBuiltDependencies:
- esbuild

View File

@@ -19,7 +19,7 @@ const argv = await yargs(hideBin(process.argv))
alias: 'f',
type: 'string',
description: 'The file to use for environment variables. (multiple files can be specified)',
default: ['.env', '.env.with-ssm'],
default: ['.env.with-ssm', '.env'],
})
.demandCommand(1, 'Error: You must provide a command to execute after --')
.alias('h', 'help')
@@ -36,7 +36,10 @@ if (!command) {
const files = argv.file && Array.isArray(argv.file) ? argv.file : [argv.file];
const hostEnv = await getEnv(files);
const env = await replaceParams(hostEnv);
const env = await replaceParams(hostEnv, {
region: argv.region,
profile: argv.profile,
});
exec({
command,

20
src/utils/aws.ts Normal file
View File

@@ -0,0 +1,20 @@
import { STSClient, GetCallerIdentityCommand } from '@aws-sdk/client-sts';
const ensureAWS = async (region?: string, profile?: string) => {
const sts = new STSClient({
region,
profile,
});
const command = new GetCallerIdentityCommand({});
try {
await sts.send(command);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error('Failed to get caller identity', errorMessage);
process.exit(1);
}
};
export { ensureAWS };

View File

@@ -1,14 +0,0 @@
const splitArgs = (args: string[]) => {
const separatorIndex = args.indexOf('--');
const actionArgs = args.slice(0, separatorIndex);
const command = args[separatorIndex + 1];
const commandArgs = args.slice(separatorIndex + 2);
return {
actionArgs,
command,
commandArgs,
};
};
export { splitArgs };

View File

@@ -2,7 +2,7 @@ import { existsSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { parse } from 'dotenv';
import { parse } from '@dotenvx/dotenvx';
import { debug } from './debug.js';

View File

@@ -1,6 +1,7 @@
import { GetParametersCommand, SSMClient } from '@aws-sdk/client-ssm';
import { GetParametersCommand, SSMClient, type Parameter } from '@aws-sdk/client-ssm';
import { debug } from './debug.js';
import { ensureAWS } from './aws.js';
const PREFIX = 'SSM:';
@@ -13,11 +14,6 @@ const replaceParams = async (
env: Record<string, string | undefined>,
{ region, profile }: ReplaceParamsOptions = {},
) => {
const ssm = new SSMClient({
region,
profile,
});
const names = Object.entries(env)
.filter(([, value]) => value?.startsWith(PREFIX))
.map(([, value]) => value?.slice(PREFIX.length))
@@ -30,18 +26,47 @@ const replaceParams = async (
return env;
}
const command = new GetParametersCommand({
Names: names,
WithDecryption: true,
await ensureAWS(region, profile);
const ssm = new SSMClient({
region,
profile,
});
// Chunk names into groups of 10 (AWS SSM GetParametersCommand limit)
const chunks: string[][] = [];
debug(`Chunking ${names.length} names into groups of 10`);
for (let i = 0; i < names.length; i += 10) {
chunks.push(names.slice(i, i + 10));
}
const response = await ssm.send(command);
if (response.InvalidParameters?.length || 0 > 0) {
console.error('Invalid SSM parameters', response.InvalidParameters);
debug(`Processing ${chunks.length} chunks`);
// Fetch parameters in chunks and combine results
const allParams: Parameter[] = [];
const allInvalidParams: string[] = [];
for (const chunk of chunks) {
const command = new GetParametersCommand({
Names: chunk,
WithDecryption: true,
});
const response = await ssm.send(command);
if (response.Parameters) {
allParams.push(...response.Parameters);
}
if (response.InvalidParameters) {
allInvalidParams.push(...response.InvalidParameters);
}
}
if (allInvalidParams.length > 0) {
console.error('Invalid SSM parameters', allInvalidParams);
process.exit(1);
}
const params = response.Parameters ?? [];
const params = allParams;
return Object.fromEntries(
Object.entries(env).map(([key, value]) => {

View File

@@ -10,6 +10,7 @@
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"noEmit": true,
"outDir": "dist",
"jsx": "react-jsx",
"isolatedModules": true,