@lendi/ssm-cli 0.0.1
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/README.md +141 -0
- package/package.json +40 -0
- package/src/commands/delete.js +51 -0
- package/src/commands/get.js +88 -0
- package/src/commands/history.js +44 -0
- package/src/commands/list.js +42 -0
- package/src/commands/put.js +32 -0
- package/src/commands/rollback.js +49 -0
- package/src/commands/tag.js +31 -0
- package/src/index.js +101 -0
- package/src/services/ssm.js +164 -0
- package/src/utils/auth.js +75 -0
- package/src/utils/logger.js +41 -0
package/README.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# ssm-cli
|
|
2
|
+
|
|
3
|
+
CLI tool for AWS SSM Parameter Store operations. Authenticates via `liam assume` before every command.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
From the repo root (Yarn workspaces):
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
yarn install
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
ssm-cli -e <environment> [-r <role>] <command> [options]
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
### Global options
|
|
20
|
+
|
|
21
|
+
| Flag | Description | Default |
|
|
22
|
+
|---|---|---|
|
|
23
|
+
| `-e, --env` | **Required.** `staging` \| `development` \| `production` | — |
|
|
24
|
+
| `-r, --role` | `poweruser` \| `dataaccess` | `poweruser` |
|
|
25
|
+
| `--verbose` | Enable verbose logging | off |
|
|
26
|
+
|
|
27
|
+
### Commands
|
|
28
|
+
|
|
29
|
+
#### `put` — Create or update a parameter
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# Create a String parameter
|
|
33
|
+
ssm-cli -e development put -n /app/config/db_host -v "db.example.com"
|
|
34
|
+
|
|
35
|
+
# Create a SecureString
|
|
36
|
+
ssm-cli -e production -r dataaccess put -n /app/config/db_password -v "secret" -t SecureString --key-id alias/aws/ssm
|
|
37
|
+
|
|
38
|
+
# Update (overwrite) an existing parameter — creates a new version
|
|
39
|
+
ssm-cli -e staging put -n /app/config/db_host -v "new-db.example.com" --overwrite
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
| Option | Description | Default |
|
|
43
|
+
|---|---|---|
|
|
44
|
+
| `-n, --name` | Parameter name (required) | — |
|
|
45
|
+
| `-v, --value` | Parameter value (required) | — |
|
|
46
|
+
| `-t, --type` | `String` \| `SecureString` | `String` |
|
|
47
|
+
| `--overwrite` | Overwrite if exists | `false` |
|
|
48
|
+
| `--key-id` | KMS key for SecureString | — |
|
|
49
|
+
|
|
50
|
+
#### `get` — Read parameter(s)
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
# Single parameter
|
|
54
|
+
ssm-cli -e staging get /app/config/db_host
|
|
55
|
+
|
|
56
|
+
# Specific version
|
|
57
|
+
ssm-cli -e staging get --ver 2 /app/config/db_host
|
|
58
|
+
|
|
59
|
+
# Decrypt a SecureString
|
|
60
|
+
ssm-cli -e production -r dataaccess get -d /app/config/db_password
|
|
61
|
+
|
|
62
|
+
# Multiple parameters at once
|
|
63
|
+
ssm-cli -e staging get /app/config/db_host /app/config/db_user
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
| Option | Description |
|
|
67
|
+
|---|---|
|
|
68
|
+
| `-d, --decrypt` | Decrypt SecureString values |
|
|
69
|
+
| `--ver <n>` | Read a specific version (single param only) |
|
|
70
|
+
| `--json` | Output raw JSON |
|
|
71
|
+
|
|
72
|
+
#### `get-by-path` — Read parameters by path hierarchy
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
ssm-cli -e staging get-by-path /app/config/
|
|
76
|
+
|
|
77
|
+
# With decryption
|
|
78
|
+
ssm-cli -e production -r dataaccess get-by-path /app/config/ -d
|
|
79
|
+
|
|
80
|
+
# Non-recursive
|
|
81
|
+
ssm-cli -e staging get-by-path /app/config/ --no-recursive
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
#### `list` — List parameters (metadata only)
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
ssm-cli -e staging list
|
|
88
|
+
|
|
89
|
+
# Filter by path
|
|
90
|
+
ssm-cli -e staging list -p /app/config/
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
#### `history` — View version history
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
ssm-cli -e staging history /app/config/db_host
|
|
97
|
+
|
|
98
|
+
# Limit results
|
|
99
|
+
ssm-cli -e staging history /app/config/db_host --max 5
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
#### `rollback` — Rollback to a previous version
|
|
103
|
+
|
|
104
|
+
Re-puts the old version's value as a new version.
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
ssm-cli -e staging rollback /app/config/db_host --ver 2
|
|
108
|
+
|
|
109
|
+
# Skip confirmation prompt
|
|
110
|
+
ssm-cli -e staging rollback /app/config/db_host --ver 2 -y
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
#### `delete` — Delete parameter(s)
|
|
114
|
+
|
|
115
|
+
**Irreversible.** Prompts for confirmation by default.
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
ssm-cli -e staging delete /app/config/db_host
|
|
119
|
+
|
|
120
|
+
# Delete multiple
|
|
121
|
+
ssm-cli -e staging delete /app/config/db_host /app/config/db_user
|
|
122
|
+
|
|
123
|
+
# Skip confirmation
|
|
124
|
+
ssm-cli -e staging delete /app/config/db_host -y
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
#### `tag` — Tag a parameter
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
ssm-cli -e staging tag /app/config/db_host --tags env=prod,team=backend
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Authentication
|
|
134
|
+
|
|
135
|
+
Every command runs `liam assume -e <environment> -r <role>` before executing. This:
|
|
136
|
+
|
|
137
|
+
1. Fetches temporary AWS credentials
|
|
138
|
+
2. May open a browser window for console login
|
|
139
|
+
3. Loads the exported credentials (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`) into the session
|
|
140
|
+
|
|
141
|
+
The process waits up to 5 minutes for the browser login to complete.
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lendi/ssm-cli",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "CLI tool for AWS SSM Parameter Store operations with liam assume integration",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"ssm-cli": "src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"start": "node src/index.js",
|
|
15
|
+
"dev": "node --watch src/index.js",
|
|
16
|
+
"lint": "eslint src/",
|
|
17
|
+
"format": "prettier --write src/"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"aws",
|
|
21
|
+
"ssm",
|
|
22
|
+
"parameter-store",
|
|
23
|
+
"cli",
|
|
24
|
+
"lendi"
|
|
25
|
+
],
|
|
26
|
+
"author": "Lendi",
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@aws-sdk/client-ssm": "^3.700.0",
|
|
33
|
+
"chalk": "^5.3.0",
|
|
34
|
+
"commander": "^11.1.0",
|
|
35
|
+
"ora": "^7.0.1"
|
|
36
|
+
},
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=18.0.0"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import logger from '../utils/logger.js';
|
|
3
|
+
import { deleteParameter, deleteParameters } from '../services/ssm.js';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import ora from 'ora';
|
|
6
|
+
import readline from 'readline';
|
|
7
|
+
|
|
8
|
+
async function confirm(message) {
|
|
9
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
10
|
+
return new Promise((resolve) => {
|
|
11
|
+
rl.question(`${message} (y/N): `, (answer) => {
|
|
12
|
+
rl.close();
|
|
13
|
+
resolve(answer.toLowerCase().trim() === 'y');
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const deleteCommand = new Command('delete')
|
|
19
|
+
.description('Delete SSM parameter(s) — this is irreversible!')
|
|
20
|
+
.argument('<names...>', 'One or more parameter names to delete')
|
|
21
|
+
.option('-y, --yes', 'Skip confirmation', false)
|
|
22
|
+
.action(async (names, opts) => {
|
|
23
|
+
if (!opts.yes) {
|
|
24
|
+
logger.warning(chalk.red('This action is IRREVERSIBLE.'));
|
|
25
|
+
const ok = await confirm(`Delete ${names.length} parameter(s)?\n ${names.join('\n ')}\n`);
|
|
26
|
+
if (!ok) {
|
|
27
|
+
logger.info('Aborted');
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const spinner = ora('Deleting parameter(s)...').start();
|
|
33
|
+
try {
|
|
34
|
+
if (names.length === 1) {
|
|
35
|
+
await deleteParameter({ name: names[0] });
|
|
36
|
+
spinner.succeed(`Deleted: ${names[0]}`);
|
|
37
|
+
} else {
|
|
38
|
+
const result = await deleteParameters({ names });
|
|
39
|
+
spinner.succeed(`Deleted ${result.deleted?.length || 0} parameter(s)`);
|
|
40
|
+
if (result.invalid?.length) {
|
|
41
|
+
logger.warning(`Invalid/not found: ${result.invalid.join(', ')}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
} catch (err) {
|
|
45
|
+
spinner.fail('Delete failed');
|
|
46
|
+
logger.error(err.message);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export default deleteCommand;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import logger from '../utils/logger.js';
|
|
3
|
+
import { getParameter, getParameters, getParametersByPath } from '../services/ssm.js';
|
|
4
|
+
import ora from 'ora';
|
|
5
|
+
|
|
6
|
+
const getCommand = new Command('get')
|
|
7
|
+
.description('Read SSM parameter(s)')
|
|
8
|
+
.argument('<names...>', 'One or more parameter names')
|
|
9
|
+
.option('-d, --decrypt', 'Decrypt SecureString values', false)
|
|
10
|
+
.option('--ver <version>', 'Read a specific parameter version')
|
|
11
|
+
.option('--json', 'Output raw JSON', false)
|
|
12
|
+
.action(async (names, opts) => {
|
|
13
|
+
const spinner = ora('Reading parameter(s)...').start();
|
|
14
|
+
try {
|
|
15
|
+
if (names.length === 1) {
|
|
16
|
+
const param = await getParameter({
|
|
17
|
+
name: names[0],
|
|
18
|
+
withDecryption: opts.decrypt,
|
|
19
|
+
version: opts.ver,
|
|
20
|
+
});
|
|
21
|
+
spinner.stop();
|
|
22
|
+
if (opts.json) {
|
|
23
|
+
logger.json(param);
|
|
24
|
+
} else {
|
|
25
|
+
logger.param(param.Name, param.Value, param.Type);
|
|
26
|
+
logger.info(`Version: ${param.Version} | Last modified: ${param.LastModifiedDate}`);
|
|
27
|
+
}
|
|
28
|
+
} else {
|
|
29
|
+
const { parameters, invalidParameters } = await getParameters({
|
|
30
|
+
names,
|
|
31
|
+
withDecryption: opts.decrypt,
|
|
32
|
+
});
|
|
33
|
+
spinner.stop();
|
|
34
|
+
if (opts.json) {
|
|
35
|
+
logger.json(parameters);
|
|
36
|
+
} else {
|
|
37
|
+
for (const p of parameters) {
|
|
38
|
+
logger.param(p.Name, p.Value, p.Type);
|
|
39
|
+
}
|
|
40
|
+
if (invalidParameters?.length) {
|
|
41
|
+
logger.warning(`Invalid parameters: ${invalidParameters.join(', ')}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
} catch (err) {
|
|
46
|
+
spinner.fail('Failed to read parameter(s)');
|
|
47
|
+
logger.error(err.message);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const getByPathCommand = new Command('get-by-path')
|
|
53
|
+
.description('Read SSM parameters by path hierarchy')
|
|
54
|
+
.argument('<path>', 'Parameter path prefix (e.g. /app/config/)')
|
|
55
|
+
.option('-d, --decrypt', 'Decrypt SecureString values', false)
|
|
56
|
+
.option('--no-recursive', 'Do not recurse into sub-paths')
|
|
57
|
+
.option('--json', 'Output raw JSON', false)
|
|
58
|
+
.action(async (path, opts) => {
|
|
59
|
+
const spinner = ora(`Reading parameters under ${path}...`).start();
|
|
60
|
+
try {
|
|
61
|
+
const params = await getParametersByPath({
|
|
62
|
+
path,
|
|
63
|
+
recursive: opts.recursive,
|
|
64
|
+
withDecryption: opts.decrypt,
|
|
65
|
+
});
|
|
66
|
+
spinner.stop();
|
|
67
|
+
|
|
68
|
+
if (params.length === 0) {
|
|
69
|
+
logger.info('No parameters found');
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (opts.json) {
|
|
74
|
+
logger.json(params);
|
|
75
|
+
} else {
|
|
76
|
+
logger.info(`Found ${params.length} parameter(s):`);
|
|
77
|
+
for (const p of params) {
|
|
78
|
+
logger.param(p.Name, p.Value, p.Type);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
} catch (err) {
|
|
82
|
+
spinner.fail('Failed to read parameters by path');
|
|
83
|
+
logger.error(err.message);
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
export { getCommand, getByPathCommand };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import logger from '../utils/logger.js';
|
|
3
|
+
import { getParameterHistory } from '../services/ssm.js';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import ora from 'ora';
|
|
6
|
+
|
|
7
|
+
const historyCommand = new Command('history')
|
|
8
|
+
.description('View version history of an SSM parameter')
|
|
9
|
+
.argument('<name>', 'Parameter name')
|
|
10
|
+
.option('--max <n>', 'Max results', parseInt)
|
|
11
|
+
.option('--json', 'Output raw JSON', false)
|
|
12
|
+
.action(async (name, opts) => {
|
|
13
|
+
const spinner = ora(`Fetching history for ${name}...`).start();
|
|
14
|
+
try {
|
|
15
|
+
const entries = await getParameterHistory({
|
|
16
|
+
name,
|
|
17
|
+
maxResults: opts.max,
|
|
18
|
+
});
|
|
19
|
+
spinner.stop();
|
|
20
|
+
|
|
21
|
+
if (entries.length === 0) {
|
|
22
|
+
logger.info('No history found');
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (opts.json) {
|
|
27
|
+
logger.json(entries);
|
|
28
|
+
} else {
|
|
29
|
+
logger.info(`${entries.length} version(s) of ${chalk.bold(name)}:\n`);
|
|
30
|
+
for (const e of entries) {
|
|
31
|
+
const type = e.Type === 'SecureString' ? chalk.red(e.Type) : chalk.green(e.Type);
|
|
32
|
+
console.log(
|
|
33
|
+
` v${e.Version} ${type} ${chalk.gray(e.LastModifiedDate?.toISOString() || '')} by ${e.LastModifiedUser || 'unknown'}`
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
} catch (err) {
|
|
38
|
+
spinner.fail('Failed to get parameter history');
|
|
39
|
+
logger.error(err.message);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export default historyCommand;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import logger from '../utils/logger.js';
|
|
3
|
+
import { describeParameters } from '../services/ssm.js';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import ora from 'ora';
|
|
6
|
+
|
|
7
|
+
const listCommand = new Command('list')
|
|
8
|
+
.description('List SSM parameters (metadata only)')
|
|
9
|
+
.option('-p, --path <path>', 'Filter by path prefix (recursive)')
|
|
10
|
+
.option('--max <n>', 'Max results per page', parseInt)
|
|
11
|
+
.option('--json', 'Output raw JSON', false)
|
|
12
|
+
.action(async (opts) => {
|
|
13
|
+
const spinner = ora('Listing parameters...').start();
|
|
14
|
+
try {
|
|
15
|
+
const params = await describeParameters({
|
|
16
|
+
path: opts.path,
|
|
17
|
+
maxResults: opts.max,
|
|
18
|
+
});
|
|
19
|
+
spinner.stop();
|
|
20
|
+
|
|
21
|
+
if (params.length === 0) {
|
|
22
|
+
logger.info('No parameters found');
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (opts.json) {
|
|
27
|
+
logger.json(params);
|
|
28
|
+
} else {
|
|
29
|
+
logger.info(`Found ${params.length} parameter(s):\n`);
|
|
30
|
+
for (const p of params) {
|
|
31
|
+
const type = p.Type === 'SecureString' ? chalk.red(p.Type) : chalk.green(p.Type);
|
|
32
|
+
console.log(` ${chalk.bold(p.Name)} ${chalk.gray(type)} v${p.Version || '?'} ${chalk.gray(p.LastModifiedDate?.toISOString() || '')}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
} catch (err) {
|
|
36
|
+
spinner.fail('Failed to list parameters');
|
|
37
|
+
logger.error(err.message);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export default listCommand;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import logger from '../utils/logger.js';
|
|
3
|
+
import { putParameter } from '../services/ssm.js';
|
|
4
|
+
import ora from 'ora';
|
|
5
|
+
|
|
6
|
+
const putCommand = new Command('put')
|
|
7
|
+
.description('Create or update an SSM parameter')
|
|
8
|
+
.requiredOption('-n, --name <name>', 'Parameter name (e.g. /app/config/db_host)')
|
|
9
|
+
.requiredOption('-v, --value <value>', 'Parameter value')
|
|
10
|
+
.option('-t, --type <type>', 'Parameter type: String | SecureString', 'String')
|
|
11
|
+
.option('--overwrite', 'Overwrite existing parameter (creates new version)', false)
|
|
12
|
+
.option('--key-id <keyId>', 'KMS key ID for SecureString (e.g. alias/aws/ssm)')
|
|
13
|
+
.action(async (opts) => {
|
|
14
|
+
const spinner = ora('Putting parameter...').start();
|
|
15
|
+
try {
|
|
16
|
+
const result = await putParameter({
|
|
17
|
+
name: opts.name,
|
|
18
|
+
value: opts.value,
|
|
19
|
+
type: opts.type,
|
|
20
|
+
overwrite: opts.overwrite,
|
|
21
|
+
keyId: opts.keyId,
|
|
22
|
+
});
|
|
23
|
+
spinner.succeed(`Parameter ${opts.overwrite ? 'updated' : 'created'}: ${opts.name}`);
|
|
24
|
+
logger.info(`Version: ${result.version}`);
|
|
25
|
+
} catch (err) {
|
|
26
|
+
spinner.fail('Failed to put parameter');
|
|
27
|
+
logger.error(err.message);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export default putCommand;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import logger from '../utils/logger.js';
|
|
3
|
+
import { rollbackParameter } from '../services/ssm.js';
|
|
4
|
+
import ora from 'ora';
|
|
5
|
+
import readline from 'readline';
|
|
6
|
+
|
|
7
|
+
async function confirm(message) {
|
|
8
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
9
|
+
return new Promise((resolve) => {
|
|
10
|
+
rl.question(`${message} (y/N): `, (answer) => {
|
|
11
|
+
rl.close();
|
|
12
|
+
resolve(answer.toLowerCase().trim() === 'y');
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const rollbackCommand = new Command('rollback')
|
|
18
|
+
.description('Rollback a parameter to a previous version (re-puts old value as new version)')
|
|
19
|
+
.argument('<name>', 'Parameter name')
|
|
20
|
+
.requiredOption('--ver <version>', 'Version to rollback to')
|
|
21
|
+
.option('-t, --type <type>', 'Override parameter type (default: keeps original type)')
|
|
22
|
+
.option('-y, --yes', 'Skip confirmation', false)
|
|
23
|
+
.action(async (name, opts) => {
|
|
24
|
+
if (!opts.yes) {
|
|
25
|
+
const ok = await confirm(
|
|
26
|
+
`Rollback ${name} to version ${opts.ver}?`
|
|
27
|
+
);
|
|
28
|
+
if (!ok) {
|
|
29
|
+
logger.info('Aborted');
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const spinner = ora('Rolling back...').start();
|
|
35
|
+
try {
|
|
36
|
+
const result = await rollbackParameter({
|
|
37
|
+
name,
|
|
38
|
+
version: parseInt(opts.ver, 10),
|
|
39
|
+
type: opts.type,
|
|
40
|
+
});
|
|
41
|
+
spinner.succeed(`Rolled back ${name} → new version ${result.version}`);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
spinner.fail('Rollback failed');
|
|
44
|
+
logger.error(err.message);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export default rollbackCommand;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import logger from '../utils/logger.js';
|
|
3
|
+
import { tagParameter } from '../services/ssm.js';
|
|
4
|
+
import ora from 'ora';
|
|
5
|
+
|
|
6
|
+
const tagCommand = new Command('tag')
|
|
7
|
+
.description('Add tags to an SSM parameter')
|
|
8
|
+
.argument('<name>', 'Parameter name')
|
|
9
|
+
.requiredOption('--tags <tags>', 'Tags as Key=Value pairs, comma-separated (e.g. env=prod,team=backend)')
|
|
10
|
+
.action(async (name, opts) => {
|
|
11
|
+
const tags = opts.tags.split(',').map((pair) => {
|
|
12
|
+
const [Key, Value] = pair.split('=');
|
|
13
|
+
if (!Key || Value === undefined) {
|
|
14
|
+
logger.error(`Invalid tag format: "${pair}". Use Key=Value`);
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
return { Key: Key.trim(), Value: Value.trim() };
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const spinner = ora('Adding tags...').start();
|
|
21
|
+
try {
|
|
22
|
+
await tagParameter({ name, tags });
|
|
23
|
+
spinner.succeed(`Tagged ${name} with ${tags.length} tag(s)`);
|
|
24
|
+
} catch (err) {
|
|
25
|
+
spinner.fail('Failed to add tags');
|
|
26
|
+
logger.error(err.message);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export default tagCommand;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import logger from './utils/logger.js';
|
|
5
|
+
import { liamAssume, VALID_ENVIRONMENTS, VALID_ROLES } from './utils/auth.js';
|
|
6
|
+
import putCommand from './commands/put.js';
|
|
7
|
+
import { getCommand, getByPathCommand } from './commands/get.js';
|
|
8
|
+
import listCommand from './commands/list.js';
|
|
9
|
+
import historyCommand from './commands/history.js';
|
|
10
|
+
import rollbackCommand from './commands/rollback.js';
|
|
11
|
+
import deleteCommand from './commands/delete.js';
|
|
12
|
+
import tagCommand from './commands/tag.js';
|
|
13
|
+
|
|
14
|
+
const program = new Command();
|
|
15
|
+
|
|
16
|
+
program
|
|
17
|
+
.name('ssm-cli')
|
|
18
|
+
.description('CLI tool for AWS SSM Parameter Store operations')
|
|
19
|
+
.version('1.0.0')
|
|
20
|
+
.option('--verbose', 'Enable verbose logging')
|
|
21
|
+
.requiredOption(
|
|
22
|
+
'-e, --env <environment>',
|
|
23
|
+
`AWS environment (${VALID_ENVIRONMENTS.join(' | ')})`
|
|
24
|
+
)
|
|
25
|
+
.option(
|
|
26
|
+
'-r, --role <role>',
|
|
27
|
+
`IAM role (${VALID_ROLES.join(' | ')})`,
|
|
28
|
+
'poweruser'
|
|
29
|
+
)
|
|
30
|
+
.hook('preAction', async (thisCommand) => {
|
|
31
|
+
if (thisCommand.opts().verbose) {
|
|
32
|
+
process.env.VERBOSE = 'true';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const { env, role } = thisCommand.opts();
|
|
36
|
+
|
|
37
|
+
// Authenticate via liam assume before running any sub-command
|
|
38
|
+
try {
|
|
39
|
+
await liamAssume(env, role);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
logger.error(err.message);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Register sub-commands
|
|
47
|
+
program.addCommand(putCommand);
|
|
48
|
+
program.addCommand(getCommand);
|
|
49
|
+
program.addCommand(getByPathCommand);
|
|
50
|
+
program.addCommand(listCommand);
|
|
51
|
+
program.addCommand(historyCommand);
|
|
52
|
+
program.addCommand(rollbackCommand);
|
|
53
|
+
program.addCommand(deleteCommand);
|
|
54
|
+
program.addCommand(tagCommand);
|
|
55
|
+
|
|
56
|
+
program.addHelpText(
|
|
57
|
+
'after',
|
|
58
|
+
`
|
|
59
|
+
Examples:
|
|
60
|
+
# Read a parameter from staging
|
|
61
|
+
$ ssm-cli -e staging get /app/config/db_host
|
|
62
|
+
|
|
63
|
+
# Read with decryption
|
|
64
|
+
$ ssm-cli -e production -r dataaccess get -d /app/config/db_password
|
|
65
|
+
|
|
66
|
+
# Create a new parameter
|
|
67
|
+
$ ssm-cli -e development put -n /app/config/db_host -v "db.example.com"
|
|
68
|
+
|
|
69
|
+
# Create a SecureString
|
|
70
|
+
$ ssm-cli -e production -r dataaccess put -n /app/config/db_password -v "secret" -t SecureString
|
|
71
|
+
|
|
72
|
+
# Update (overwrite) a parameter
|
|
73
|
+
$ ssm-cli -e staging put -n /app/config/db_host -v "new-db.example.com" --overwrite
|
|
74
|
+
|
|
75
|
+
# List all parameters under a path
|
|
76
|
+
$ ssm-cli -e staging get-by-path /app/config/
|
|
77
|
+
|
|
78
|
+
# List parameter metadata
|
|
79
|
+
$ ssm-cli -e staging list -p /app/config/
|
|
80
|
+
|
|
81
|
+
# View version history
|
|
82
|
+
$ ssm-cli -e staging history /app/config/db_host
|
|
83
|
+
|
|
84
|
+
# Rollback to an old version
|
|
85
|
+
$ ssm-cli -e staging rollback /app/config/db_host --ver 2
|
|
86
|
+
|
|
87
|
+
# Delete a parameter
|
|
88
|
+
$ ssm-cli -e staging delete /app/config/db_host
|
|
89
|
+
|
|
90
|
+
# Tag a parameter
|
|
91
|
+
$ ssm-cli -e staging tag /app/config/db_host --tags env=prod,team=backend
|
|
92
|
+
`
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
// Error handling
|
|
96
|
+
process.on('unhandledRejection', (reason) => {
|
|
97
|
+
logger.error(`Unhandled rejection: ${reason}`);
|
|
98
|
+
process.exit(1);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
program.parseAsync();
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SSMClient,
|
|
3
|
+
PutParameterCommand,
|
|
4
|
+
GetParameterCommand,
|
|
5
|
+
GetParametersCommand,
|
|
6
|
+
GetParametersByPathCommand,
|
|
7
|
+
DescribeParametersCommand,
|
|
8
|
+
GetParameterHistoryCommand,
|
|
9
|
+
DeleteParameterCommand,
|
|
10
|
+
DeleteParametersCommand,
|
|
11
|
+
AddTagsToResourceCommand,
|
|
12
|
+
} from '@aws-sdk/client-ssm';
|
|
13
|
+
import logger from '../utils/logger.js';
|
|
14
|
+
|
|
15
|
+
let client = null;
|
|
16
|
+
|
|
17
|
+
function getClient() {
|
|
18
|
+
if (!client) {
|
|
19
|
+
client = new SSMClient({});
|
|
20
|
+
}
|
|
21
|
+
return client;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 1. Create / 2. Update a parameter */
|
|
25
|
+
export async function putParameter({ name, value, type = 'String', overwrite = false, keyId }) {
|
|
26
|
+
const params = {
|
|
27
|
+
Name: name,
|
|
28
|
+
Value: value,
|
|
29
|
+
Type: type,
|
|
30
|
+
Overwrite: overwrite,
|
|
31
|
+
};
|
|
32
|
+
if (keyId) params.KeyId = keyId;
|
|
33
|
+
|
|
34
|
+
const result = await getClient().send(new PutParameterCommand(params));
|
|
35
|
+
return { version: result.Version, tier: result.Tier };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 3. Read a single parameter (latest) */
|
|
39
|
+
export async function getParameter({ name, withDecryption = false, version }) {
|
|
40
|
+
const paramName = version ? `${name}:${version}` : name;
|
|
41
|
+
const result = await getClient().send(
|
|
42
|
+
new GetParameterCommand({
|
|
43
|
+
Name: paramName,
|
|
44
|
+
WithDecryption: withDecryption,
|
|
45
|
+
})
|
|
46
|
+
);
|
|
47
|
+
return result.Parameter;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 5. Read multiple parameters */
|
|
51
|
+
export async function getParameters({ names, withDecryption = false }) {
|
|
52
|
+
const result = await getClient().send(
|
|
53
|
+
new GetParametersCommand({
|
|
54
|
+
Names: names,
|
|
55
|
+
WithDecryption: withDecryption,
|
|
56
|
+
})
|
|
57
|
+
);
|
|
58
|
+
return {
|
|
59
|
+
parameters: result.Parameters,
|
|
60
|
+
invalidParameters: result.InvalidParameters,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 6. Read parameters by path */
|
|
65
|
+
export async function getParametersByPath({ path, recursive = true, withDecryption = false }) {
|
|
66
|
+
const allParams = [];
|
|
67
|
+
let nextToken;
|
|
68
|
+
|
|
69
|
+
do {
|
|
70
|
+
const result = await getClient().send(
|
|
71
|
+
new GetParametersByPathCommand({
|
|
72
|
+
Path: path,
|
|
73
|
+
Recursive: recursive,
|
|
74
|
+
WithDecryption: withDecryption,
|
|
75
|
+
NextToken: nextToken,
|
|
76
|
+
})
|
|
77
|
+
);
|
|
78
|
+
allParams.push(...(result.Parameters || []));
|
|
79
|
+
nextToken = result.NextToken;
|
|
80
|
+
} while (nextToken);
|
|
81
|
+
|
|
82
|
+
return allParams;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 7. List / describe parameters (metadata only) */
|
|
86
|
+
export async function describeParameters({ path, maxResults } = {}) {
|
|
87
|
+
const allParams = [];
|
|
88
|
+
let nextToken;
|
|
89
|
+
const params = {};
|
|
90
|
+
|
|
91
|
+
if (path) {
|
|
92
|
+
params.ParameterFilters = [
|
|
93
|
+
{ Key: 'Path', Option: 'Recursive', Values: [path] },
|
|
94
|
+
];
|
|
95
|
+
}
|
|
96
|
+
if (maxResults) params.MaxResults = maxResults;
|
|
97
|
+
|
|
98
|
+
do {
|
|
99
|
+
if (nextToken) params.NextToken = nextToken;
|
|
100
|
+
const result = await getClient().send(new DescribeParametersCommand(params));
|
|
101
|
+
allParams.push(...(result.Parameters || []));
|
|
102
|
+
nextToken = result.NextToken;
|
|
103
|
+
// If maxResults is set, only fetch one page
|
|
104
|
+
if (maxResults) break;
|
|
105
|
+
} while (nextToken);
|
|
106
|
+
|
|
107
|
+
return allParams;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** 8. View parameter history */
|
|
111
|
+
export async function getParameterHistory({ name, maxResults }) {
|
|
112
|
+
const allHistory = [];
|
|
113
|
+
let nextToken;
|
|
114
|
+
|
|
115
|
+
do {
|
|
116
|
+
const params = { Name: name };
|
|
117
|
+
if (maxResults) params.MaxResults = maxResults;
|
|
118
|
+
if (nextToken) params.NextToken = nextToken;
|
|
119
|
+
|
|
120
|
+
const result = await getClient().send(new GetParameterHistoryCommand(params));
|
|
121
|
+
allHistory.push(...(result.Parameters || []));
|
|
122
|
+
nextToken = result.NextToken;
|
|
123
|
+
if (maxResults) break;
|
|
124
|
+
} while (nextToken);
|
|
125
|
+
|
|
126
|
+
return allHistory;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 9. Rollback – read old version, then put as new version */
|
|
130
|
+
export async function rollbackParameter({ name, version, type }) {
|
|
131
|
+
logger.info(`Reading version ${version} of ${name}...`);
|
|
132
|
+
const oldParam = await getParameter({ name, version, withDecryption: true });
|
|
133
|
+
const oldValue = oldParam.Value;
|
|
134
|
+
const paramType = type || oldParam.Type;
|
|
135
|
+
|
|
136
|
+
logger.info(`Re-putting old value as new version (type: ${paramType})...`);
|
|
137
|
+
return putParameter({ name, value: oldValue, type: paramType, overwrite: true });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** 10. Delete a single parameter */
|
|
141
|
+
export async function deleteParameter({ name }) {
|
|
142
|
+
await getClient().send(new DeleteParameterCommand({ Name: name }));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** 10. Delete multiple parameters */
|
|
146
|
+
export async function deleteParameters({ names }) {
|
|
147
|
+
const result = await getClient().send(new DeleteParametersCommand({ Names: names }));
|
|
148
|
+
return {
|
|
149
|
+
deleted: result.DeletedParameters,
|
|
150
|
+
invalid: result.InvalidParameters,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** 11. Tag a parameter */
|
|
155
|
+
export async function tagParameter({ name, tags }) {
|
|
156
|
+
// tags = [{ Key: 'env', Value: 'prod' }, ...]
|
|
157
|
+
await getClient().send(
|
|
158
|
+
new AddTagsToResourceCommand({
|
|
159
|
+
ResourceType: 'Parameter',
|
|
160
|
+
ResourceId: name,
|
|
161
|
+
Tags: tags,
|
|
162
|
+
})
|
|
163
|
+
);
|
|
164
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
import logger from './logger.js';
|
|
3
|
+
import ora from 'ora';
|
|
4
|
+
|
|
5
|
+
const VALID_ENVIRONMENTS = ['staging', 'development', 'production'];
|
|
6
|
+
const VALID_ROLES = ['poweruser', 'dataaccess'];
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Run `liam assume <environment> <role>` and load the exported
|
|
10
|
+
* AWS credentials into the current process environment.
|
|
11
|
+
*
|
|
12
|
+
* `liam assume` prints shell export statements (and may open a
|
|
13
|
+
* browser for console login), so we capture stdout and parse
|
|
14
|
+
* the KEY=VALUE pairs.
|
|
15
|
+
*/
|
|
16
|
+
export async function liamAssume(environment, role) {
|
|
17
|
+
if (!VALID_ENVIRONMENTS.includes(environment)) {
|
|
18
|
+
throw new Error(
|
|
19
|
+
`Invalid environment "${environment}". Must be one of: ${VALID_ENVIRONMENTS.join(', ')}`
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
if (!VALID_ROLES.includes(role)) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
`Invalid role "${role}". Must be one of: ${VALID_ROLES.join(', ')}`
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const cmd = `liam assume -e ${environment} -r ${role}`;
|
|
29
|
+
const spinner = ora(`Running: ${cmd} (this may open a browser for login)...`).start();
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
// liam assume outputs export statements like:
|
|
33
|
+
// export AWS_ACCESS_KEY_ID=AKIA...
|
|
34
|
+
// export AWS_SECRET_ACCESS_KEY=...
|
|
35
|
+
// export AWS_SESSION_TOKEN=...
|
|
36
|
+
// We capture that output, then parse and inject into process.env
|
|
37
|
+
const output = execSync(cmd, {
|
|
38
|
+
encoding: 'utf-8',
|
|
39
|
+
// Give generous timeout – browser login may take a while
|
|
40
|
+
timeout: 5 * 60 * 1000, // 5 minutes
|
|
41
|
+
stdio: ['inherit', 'pipe', 'inherit'],
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const exportLines = output
|
|
45
|
+
.split('\n')
|
|
46
|
+
.filter((line) => line.startsWith('export '));
|
|
47
|
+
|
|
48
|
+
let credCount = 0;
|
|
49
|
+
for (const line of exportLines) {
|
|
50
|
+
// export KEY=VALUE or export KEY="VALUE"
|
|
51
|
+
const match = line.match(/^export\s+([A-Z_]+)=["']?(.+?)["']?$/);
|
|
52
|
+
if (match) {
|
|
53
|
+
const [, key, value] = match;
|
|
54
|
+
process.env[key] = value;
|
|
55
|
+
credCount++;
|
|
56
|
+
logger.debug(`Set ${key}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (credCount === 0) {
|
|
61
|
+
spinner.warn('liam assume completed but no credentials were exported');
|
|
62
|
+
logger.warning('Make sure liam is installed and the command is correct');
|
|
63
|
+
} else {
|
|
64
|
+
spinner.succeed(`Authenticated to ${environment} as ${role} (${credCount} vars loaded)`);
|
|
65
|
+
}
|
|
66
|
+
} catch (err) {
|
|
67
|
+
spinner.fail('liam assume failed');
|
|
68
|
+
if (err.status) {
|
|
69
|
+
throw new Error(`liam assume exited with code ${err.status}`);
|
|
70
|
+
}
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export { VALID_ENVIRONMENTS, VALID_ROLES };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
|
|
3
|
+
class Logger {
|
|
4
|
+
constructor() {
|
|
5
|
+
this.isVerbose = process.env.VERBOSE === 'true';
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
info(message) {
|
|
9
|
+
console.log(chalk.blue('ℹ'), message);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
success(message) {
|
|
13
|
+
console.log(chalk.green('✓'), message);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
warning(message) {
|
|
17
|
+
console.log(chalk.yellow('⚠'), message);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
error(message) {
|
|
21
|
+
console.error(chalk.red('✗'), message);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
debug(message) {
|
|
25
|
+
if (this.isVerbose) {
|
|
26
|
+
console.log(chalk.gray('🔍'), message);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
param(name, value, type) {
|
|
31
|
+
const typeColor = type === 'SecureString' ? chalk.red(type) : chalk.green(type);
|
|
32
|
+
console.log(` ${chalk.bold(name)} ${chalk.gray('=')} ${value} ${chalk.gray(`[${typeColor}]`)}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
json(data) {
|
|
36
|
+
console.log(JSON.stringify(data, null, 2));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const logger = new Logger();
|
|
41
|
+
export default logger;
|