mirror of
https://github.com/morten-olsen/with-ssm.git
synced 2026-02-08 00:46:23 +01:00
init
This commit is contained in:
1
.env.with-ssm
Normal file
1
.env.with-ssm
Normal file
@@ -0,0 +1 @@
|
|||||||
|
PASSWORD=SSM:/test/hfd/rds/DB_USER
|
||||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
/node_modules/
|
||||||
|
/dist/
|
||||||
|
/coverage/
|
||||||
|
/.env
|
||||||
18
.prettierrc.json
Normal file
18
.prettierrc.json
Normal 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
25
.u8.json
Normal 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
14
Makefile
Normal 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
46
README.md
Normal 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
2
bin/bin.js
Executable file
@@ -0,0 +1,2 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import '../dist/start.js'
|
||||||
51
eslint.config.mjs
Normal file
51
eslint.config.mjs
Normal 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
41
package.json
Normal 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
5804
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
src/start.ts
Normal file
45
src/start.ts
Normal 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
14
src/utils/cli.ts
Normal 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
5
src/utils/debug.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { debuglog } from 'node:util';
|
||||||
|
|
||||||
|
const debug = debuglog('with-ssm');
|
||||||
|
|
||||||
|
export { debug };
|
||||||
25
src/utils/env.ts
Normal file
25
src/utils/env.ts
Normal 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
30
src/utils/exec.ts
Normal 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
59
src/utils/ssm.ts
Normal 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
21
tsconfig.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user