This commit is contained in:
Morten Olsen
2025-08-08 13:36:38 +02:00
commit 319f311444
17 changed files with 6205 additions and 0 deletions

1
.env.with-ssm Normal file
View File

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

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
/node_modules/
/dist/
/coverage/
/.env

18
.prettierrc.json Normal file
View File

@@ -0,0 +1,18 @@
{
"arrowParens": "always",
"bracketSpacing": true,
"htmlWhitespaceSensitivity": "css",
"insertPragma": false,
"bracketSameLine": false,
"jsxSingleQuote": false,
"printWidth": 120,
"proseWrap": "preserve",
"quoteProps": "as-needed",
"requirePragma": false,
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "all",
"useTabs": false,
"singleAttributePerLine": false
}

25
.u8.json Normal file
View File

@@ -0,0 +1,25 @@
{
"values": {
"monoRepo": false,
"packageVersion": "1.0.0"
},
"entries": [
{
"timestamp": "2025-08-06T15:11:41.427Z",
"template": "pkg",
"values": {
"monoRepo": false,
"packageName": "@0north/with-ssm",
"packageVersion": "1.0.0"
}
},
{
"timestamp": "2025-08-08T06:59:01.701Z",
"template": "eslint",
"values": {
"monoRepo": false,
"packageVersion": "1.0.0"
}
}
]
}

14
Makefile Normal file
View File

@@ -0,0 +1,14 @@
node_modules:
pnpm install
build: node_modules
pnpm run build
build-dev: node_modules
pnpm run build:dev
test: node_modules
pnpm run test
install: node_modules
npm link

46
README.md Normal file
View File

@@ -0,0 +1,46 @@
# with-ssm
## Description
Short description of what the tool does
Describe the value of not having to store secrets on disk and ensure they are
always up to date, and why it is now possible to commit the `.env` (or
preferably the `.env.with-ssm` to avoid the caviots)
## Caviouts
- If the application reads the `.env` file it may override the replaced
variables after, resulting in the original variables being used. In those
cases, sustution could be used in implementaions which supports it
`${NPM_TOKEN:-SSM:/access/npm_token}` or using a `.env.with-ssm` file instead,
which would not be read by the application
- If the `.env` file is committed to the repo, it is important to remember to
avoid deploying it along with the application.
## Install
```
npm i -g @morten-olsen/with-ssm
```
## Usage
### Using environment variables
```
NPM_TOKEN="SSM:/access/npm_token" with-ssm --debug -- npm whoami
```
### Using files
`.env.with-ssm` or `.env`
```
NPM_TOKEN="SSM:/access/npm_token"
```
```
with-ssm --debug -- npm whoami
```

2
bin/bin.js Executable file
View File

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

51
eslint.config.mjs Normal file
View File

@@ -0,0 +1,51 @@
import { FlatCompat } from '@eslint/eslintrc';
import importPlugin from 'eslint-plugin-import';
import eslint from '@eslint/js';
import eslintConfigPrettier from 'eslint-config-prettier';
import tseslint from 'typescript-eslint';
const compat = new FlatCompat({
baseDirectory: import.meta.__dirname,
resolvePluginsRelativeTo: import.meta.__dirname,
});
export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.strict,
...tseslint.configs.stylistic,
eslintConfigPrettier,
{
files: ['**/*.{ts,tsxx}'],
extends: [importPlugin.flatConfigs.recommended, importPlugin.flatConfigs.typescript],
rules: {
'import/no-unresolved': 'off',
'import/extensions': ['error', 'ignorePackages'],
'import/exports-last': 'error',
'import/no-default-export': 'error',
'import/order': [
'error',
{
groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'],
'newlines-between': 'always',
},
],
'import/no-duplicates': 'error',
},
},
{
rules: {
'@typescript-eslint/consistent-type-definitions': ['error', 'type'],
},
},
{
files: ['**.d.ts'],
rules: {
'@typescript-eslint/triple-slash-reference': 'off',
'@typescript-eslint/consistent-type-definitions': 'off',
},
},
...compat.extends('plugin:prettier/recommended'),
{
ignores: ['**/node_modules/', '**/dist/', '**/.turbo/', '**/generated/'],
},
);

41
package.json Normal file
View File

@@ -0,0 +1,41 @@
{
"type": "module",
"bin": {
"with-ssm": "./bin/bin.js"
},
"scripts": {
"test:lint": "eslint",
"build": "tsc --build",
"build:dev": "tsc --build --watch",
"test:unit": "vitest --run --passWithNoTests",
"test": "pnpm run \"/^test:/\""
},
"packageManager": "pnpm@10.6.0",
"files": [
"dist"
],
"devDependencies": {
"@eslint/eslintrc": "3.3.1",
"@eslint/js": "9.32.0",
"@pnpm/find-workspace-packages": "6.0.9",
"@types/node": "24.2.0",
"@types/yargs": "^17.0.33",
"@vitest/coverage-v8": "3.2.4",
"eslint": "9.32.0",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-prettier": "5.5.4",
"prettier": "3.6.2",
"typescript": "5.9.2",
"typescript-eslint": "8.39.0",
"vitest": "3.2.4"
},
"name": "@0north/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"
}
}

5804
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

45
src/start.ts Normal file
View File

@@ -0,0 +1,45 @@
import yargs from 'yargs/yargs';
import { hideBin } from 'yargs/helpers';
import { exec } from './utils/exec.js';
import { getEnv } from './utils/env.js';
import { replaceParams } from './utils/ssm.js';
const argv = await yargs(hideBin(process.argv))
.usage('Usage: $0 [options] -- <command>')
.option('region', {
type: 'string',
description: 'The AWS region to use for SSM.',
})
.option('profile', {
type: 'string',
description: 'The AWS profile to use from your credentials file.',
})
.option('file', {
alias: 'f',
type: 'string',
description: 'The file to use for environment variables. (multiple files can be specified)',
default: ['.env', '.env.with-ssm'],
})
.demandCommand(1, 'Error: You must provide a command to execute after --')
.alias('h', 'help')
.epilogue('For more information, check the documentation.')
.parse();
const command = argv._[0] as string;
const commandArgs = argv._.slice(1).map(String);
if (!command) {
console.error('No command provided');
process.exit(1);
}
const files = argv.file && Array.isArray(argv.file) ? argv.file : [argv.file];
const hostEnv = await getEnv(files);
const env = await replaceParams(hostEnv);
exec({
command,
env,
args: commandArgs,
});

14
src/utils/cli.ts Normal file
View File

@@ -0,0 +1,14 @@
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 };

5
src/utils/debug.ts Normal file
View File

@@ -0,0 +1,5 @@
import { debuglog } from 'node:util';
const debug = debuglog('with-ssm');
export { debug };

25
src/utils/env.ts Normal file
View File

@@ -0,0 +1,25 @@
import { existsSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { parse } from 'dotenv';
import { debug } from './debug.js';
const getEnv = async (files: string[]) => {
const env = { ...process.env };
for (const file of files) {
if (existsSync(resolve(process.cwd(), file))) {
debug(`Loading ${file}`);
const content = await readFile(file, 'utf8');
const parsed = parse(content);
debug(`Parsed ${file}\n${JSON.stringify(parsed, null, 2)}`);
Object.assign(env, parsed);
}
}
return env;
};
export { getEnv };

30
src/utils/exec.ts Normal file
View File

@@ -0,0 +1,30 @@
import { spawn } from 'child_process';
type ExecOptions = {
command: string;
args: string[];
cwd?: string;
env?: Record<string, string | undefined>;
};
const exec = (options: ExecOptions) => {
const { command, args, cwd, env } = options;
const child = spawn(command, args, { cwd, env, stdio: 'inherit' });
const killChild = () => child.kill();
process.on('SIGINT', killChild);
process.on('SIGTERM', killChild);
child.on('exit', (code) => {
process.exit(code ?? 1);
});
child.on('error', (err) => {
console.error(`[with-ssm] ❌ Failed to start command "${command}".`);
console.error(err.message);
process.exit(1);
});
return child;
};
export { exec };

59
src/utils/ssm.ts Normal file
View File

@@ -0,0 +1,59 @@
import { GetParametersCommand, SSMClient } from '@aws-sdk/client-ssm';
import { debug } from './debug.js';
const PREFIX = 'SSM:';
type ReplaceParamsOptions = {
region?: string;
profile?: string;
};
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))
.filter((value) => value !== undefined);
debug(`Replacing ${names.length} parameters`);
debug(`Names: ${names.join(', ')}`);
if (names.length === 0) {
return env;
}
const command = new GetParametersCommand({
Names: names,
WithDecryption: true,
});
const response = await ssm.send(command);
if (response.InvalidParameters?.length || 0 > 0) {
console.error('Invalid SSM parameters', response.InvalidParameters);
process.exit(1);
}
const params = response.Parameters ?? [];
return Object.fromEntries(
Object.entries(env).map(([key, value]) => {
if (value?.startsWith(PREFIX)) {
const param = params.find((param) => `SSM:${param.Name}` === value);
debug(`Replacing ${key} with ${param?.Value}`);
return [key, param?.Value];
}
return [key, value];
}),
);
};
export { replaceParams };

21
tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"outDir": "dist",
"jsx": "react-jsx",
"isolatedModules": true,
"verbatimModuleSyntax": true
},
"include": [
"src/**/*.ts"
]
}