@agilecustoms/envctl 0.1.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.
@@ -0,0 +1,46 @@
1
+ name: Client Build and Release
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+
8
+ jobs:
9
+ Build:
10
+ uses: ./.github/workflows/build.yml
11
+ with:
12
+ artifacts: true
13
+ secrets: inherit
14
+ permissions:
15
+ id-token: write
16
+ contents: read
17
+
18
+
19
+ Release:
20
+ needs:
21
+ - Build
22
+ runs-on: ubuntu-latest
23
+ defaults:
24
+ run:
25
+ working-directory: client
26
+ permissions:
27
+ id-token: write
28
+ contents: write
29
+ steps:
30
+ - name: Checkout
31
+ uses: actions/checkout@v4
32
+ with:
33
+ token: ${{ secrets.GH_ACTIONS_TOKEN }} # Admin PAT to bypass push protection on 'main' branch
34
+
35
+ - name: Download artifacts
36
+ uses: actions/download-artifact@v4
37
+
38
+ - name: Release
39
+ id: release
40
+ uses: agilecustoms/gha-release@main
41
+ with:
42
+ aws-account: ${{ vars.AWS_ACCOUNT_DIST }}
43
+ npmjs-token: ${{ secrets.NPMJS_TOKEN }}
44
+
45
+ - name: Summary
46
+ run: echo "### Released ${{ steps.release.outputs.version }} :pushpin:" >> $GITHUB_STEP_SUMMARY
@@ -0,0 +1,46 @@
1
+ name: Client Build
2
+
3
+ on:
4
+ push:
5
+ branches-ignore:
6
+ - main
7
+ workflow_call:
8
+ inputs:
9
+ artifacts:
10
+ required: false
11
+ type: boolean
12
+ default: false
13
+ description: upload artifacts or not
14
+
15
+ jobs:
16
+ Build:
17
+ runs-on: ubuntu-latest
18
+ steps:
19
+ - name: Checkout
20
+ uses: actions/checkout@v4
21
+
22
+ - name: Setup Node
23
+ uses: actions/setup-node@v4
24
+ with:
25
+ node-version: 23
26
+ cache: npm
27
+
28
+ - name: Install dependencies
29
+ run: npm ci
30
+
31
+ - name: Lint
32
+ run: npm run lint
33
+
34
+ - name: Test
35
+ run: npm run test
36
+
37
+ - name: Build
38
+ run: npm run build
39
+
40
+ - name: Upload artifacts
41
+ if: inputs.artifacts
42
+ uses: actions/upload-artifact@v4
43
+ with:
44
+ path: dist # take everything from dist/ folder
45
+ name: dist # and create artifact named dist (so later on the download action will create <repo-root>/dist/ folder)
46
+ # if not specify name - it will be available as 'artifact' dir
package/Makefile ADDED
@@ -0,0 +1,28 @@
1
+ _aws-login:
2
+ @aws sso login
3
+
4
+ # npm list --all - show dependency tree
5
+ app0-install-deps:
6
+ @npm install
7
+
8
+ app0-update-deps:
9
+ @npm update; npm outdated
10
+
11
+ app1-lint:
12
+ @npm run lint
13
+
14
+ app1-lint-fix:
15
+ @npm run lint:fix
16
+
17
+ app2-test:
18
+ @npm run test
19
+
20
+ app3-build:
21
+ @npm run build
22
+
23
+ # npm cache clean --force
24
+ # cd ~/.npm; open .
25
+ app4-dry-run:
26
+ @set -e; \
27
+ echo "cd in other directory, otherwise npx picks up local package :("; cd ..; \
28
+ npx -y --force @agilecustoms/envctl --version
package/README.md ADDED
@@ -0,0 +1,35 @@
1
+ # envctl
2
+ - npm [@agilecustoms/envctl](https://www.npmjs.com/package/@agilecustoms/envctl)
3
+ - npm [agilecustoms/packages](https://www.npmjs.com/settings/agilecustoms/packages) (admin view)
4
+
5
+ ## npmjs setup
6
+ 1. Login in npmjs.com
7
+ 2. Create organization `agilecustoms` this will create scope `@agilecustoms` (one org => exactly one scope, also scope can be created w/o org)
8
+ 3. Go to your user > Access Tokens > Generate New Token > New Granular Access Token
9
+ 1. Token name: `agilecustoms-ci`
10
+ 2. Packages and scopes
11
+ 1. Permissions: Read and write
12
+ 2. Only select packages and scopes: `@agilecustoms`
13
+ 3. Organizations (keep as is)
14
+ 4. Save token in GitHub > org > Settings > Secrets and variables > Actions > Secrets > New organization secret
15
+ 1. Name `NPMJS_TOKEN`
16
+ 2. Repository access: `envctl` only
17
+
18
+ dev-env is a microservice hosted in 'maintenance' account and working as garbage collector: every environment first
19
+ created in dev-env and then 'managed' by dev-env: it deletes env when it is not in use anymore OR can extend lifetime.
20
+ Creation API yields unique ID, so you can safely manage env (delete, extend lifetime) via this ID. But creation API
21
+ need to be secured. There are two main use cases:
22
+ 1. create environment from CI (mainly ephemeral envs)
23
+ 2. create env from dev machine
24
+
25
+ I (Alex C) chosen IAM authorization as common denominator:
26
+ 1. on CI - use OIDC to assume role `/ci/deployer`
27
+ 2. on dev machine - use SSO and profile chaining to assume role `/ci/deployer`
28
+
29
+ Then as `/ci/deployer` --call--> dev-env HTTP API (exposed with API Gateway with IAM authorizer)
30
+
31
+ Now problem is: any request needs to be signed with AWS signature v4. Originally I planned to use bash scripts, but it
32
+ quickly became bulky and hard to maintain. Then I thought about Node.js - it is available on dev machines and
33
+ in GitHub actions (namely in Ubuntu runners). How to distribute it? First I thought about using `ncc` to bundle in one
34
+ big .js file (as I do for `gha-upload-3` and `gha-healthcheck`) but it will be hard to use on dev machine...
35
+ So I ended up with publishing this client as npm package in CodeArtifact and then running from anywhere with `npx`!
@@ -0,0 +1,14 @@
1
+ import { Command } from 'commander';
2
+ import { envCtl } from '../container.js';
3
+ import { wrap } from '../exceptions.js';
4
+ export function ephemeral(program) {
5
+ program
6
+ .command('ephemeral')
7
+ .description('Create new ephemeral environment')
8
+ .requiredOption('-k, --key <key>', 'Key used to store env state (s3 key in case of AWS). Can be git hash, feature-a or {project-code}-{env-name}')
9
+ .action(wrap(handler));
10
+ }
11
+ async function handler(options) {
12
+ const { key } = options;
13
+ return await envCtl.createEphemeralEnv(key);
14
+ }
@@ -0,0 +1,13 @@
1
+ import { Command } from 'commander';
2
+ import { envCtl } from '../container.js';
3
+ import { wrap } from '../exceptions.js';
4
+ export function deleteIt(program) {
5
+ program
6
+ .command('delete')
7
+ .description('Delete a development environment')
8
+ .argument('<string>', 'key used to create/register the environment')
9
+ .action(wrap(handler));
10
+ }
11
+ async function handler(key) {
12
+ await envCtl.delete(key);
13
+ }
@@ -0,0 +1,3 @@
1
+ export { ephemeral } from './createEphemeral.js';
2
+ export { register } from './register.js';
3
+ export { deleteIt } from './delete.js';
@@ -0,0 +1,15 @@
1
+ import { Command } from 'commander';
2
+ import { envCtl } from '../container.js';
3
+ import { wrap } from '../exceptions.js';
4
+ export function register(program) {
5
+ program
6
+ .command('register')
7
+ .description('Create new or update existing dev environment')
8
+ .requiredOption('-k, --key <key>', 'Key used to store env state (s3 key in case of AWS). Can be git hash, feature-a or {project-code}-{env-name}')
9
+ .requiredOption('-o, --owner <owner>', 'Environment owner (GH username)')
10
+ .action(wrap(handler));
11
+ }
12
+ async function handler(options) {
13
+ const { key, owner } = options;
14
+ return await envCtl.registerEnv(key, owner);
15
+ }
@@ -0,0 +1,7 @@
1
+ import { STSClient } from '@aws-sdk/client-sts';
2
+ import { DevEnvClient, EnvCtl, HttpClient } from './service/index.js';
3
+ const stsClient = new STSClient();
4
+ const httpClient = new HttpClient();
5
+ const devEnvClient = new DevEnvClient(httpClient);
6
+ const envCtl = new EnvCtl(stsClient, devEnvClient);
7
+ export { envCtl };
@@ -0,0 +1,26 @@
1
+ export class KnownException extends Error {
2
+ constructor(message) {
3
+ super(message);
4
+ this.name = 'KnownException';
5
+ }
6
+ }
7
+ export function wrap(callable) {
8
+ return async (...args) => {
9
+ let result;
10
+ try {
11
+ result = await callable(...args);
12
+ }
13
+ catch (error) {
14
+ if (error instanceof KnownException) {
15
+ console.error(error.message);
16
+ }
17
+ else {
18
+ console.error('Unknown error:', error);
19
+ }
20
+ process.exit(1);
21
+ }
22
+ if (result !== undefined) {
23
+ console.log(result);
24
+ }
25
+ };
26
+ }
package/dist/index.js ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from 'module';
3
+ import { Command } from 'commander';
4
+ import { deleteIt, ephemeral, register } from './commands/index.js';
5
+ const require = createRequire(import.meta.url);
6
+ const { version } = require('../package.json');
7
+ const program = new Command();
8
+ program
9
+ .name('envctl')
10
+ .description('CLI to manage environments')
11
+ .version(version);
12
+ register(program);
13
+ ephemeral(program);
14
+ deleteIt(program);
15
+ program.parse(process.argv);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,27 @@
1
+ import { HttpClient } from './HttpClient.js';
2
+ export class DevEnvClient {
3
+ httpClient;
4
+ constructor(httpClient) {
5
+ this.httpClient = httpClient;
6
+ }
7
+ async registerEnv(env) {
8
+ return await this.send('PUT', '/ci/env', env);
9
+ }
10
+ async createEphemeralEnv(env) {
11
+ return await this.send('POST', '/ci/env', env);
12
+ }
13
+ async delete(envId) {
14
+ await this.httpClient.fetch(`/ci/env/${envId}`, {
15
+ method: 'DELETE'
16
+ });
17
+ }
18
+ async send(method, path, env) {
19
+ return this.httpClient.fetch(path, {
20
+ method,
21
+ body: JSON.stringify(env),
22
+ headers: {
23
+ 'Content-Type': 'application/json'
24
+ }
25
+ });
26
+ }
27
+ }
@@ -0,0 +1,43 @@
1
+ import { GetCallerIdentityCommand, STSClient } from '@aws-sdk/client-sts';
2
+ import { KnownException } from '../exceptions.js';
3
+ import { DevEnvClient } from './DevEnvClient.js';
4
+ export class EnvCtl {
5
+ stsClient;
6
+ devEnvClient;
7
+ constructor(stsClient, devEnvClient) {
8
+ this.stsClient = stsClient;
9
+ this.devEnvClient = devEnvClient;
10
+ }
11
+ async getAccount() {
12
+ const command = new GetCallerIdentityCommand({});
13
+ let response;
14
+ try {
15
+ response = await this.stsClient.send(command);
16
+ }
17
+ catch (error) {
18
+ if (error instanceof Error) {
19
+ const message = error.message;
20
+ if (message.startsWith('The SSO session token') || message.startsWith('Token is expired')) {
21
+ throw new KnownException(error.message);
22
+ }
23
+ }
24
+ throw new Error('Error fetching account', { cause: error });
25
+ }
26
+ return response.Account;
27
+ }
28
+ async registerEnv(key, owner) {
29
+ const account = await this.getAccount();
30
+ const env = { account, key, owner };
31
+ return await this.devEnvClient.registerEnv(env);
32
+ }
33
+ async createEphemeralEnv(key) {
34
+ const account = await this.getAccount();
35
+ const env = { account, key };
36
+ return await this.devEnvClient.createEphemeralEnv(env);
37
+ }
38
+ async delete(key) {
39
+ const account = await this.getAccount();
40
+ const envId = `${account}-${key}`;
41
+ return this.devEnvClient.delete(envId);
42
+ }
43
+ }
@@ -0,0 +1,51 @@
1
+ import { fromEnv, fromSSO } from '@aws-sdk/credential-providers';
2
+ import aws4 from 'aws4';
3
+ import { KnownException } from '../exceptions.js';
4
+ const HOST = 'dev-env.maintenance.agilecustoms.com';
5
+ export class HttpClient {
6
+ async fetch(path, options) {
7
+ const creds = await this.getCredentials();
8
+ const requestOptions = {
9
+ method: options.method,
10
+ body: options.body,
11
+ headers: options.headers,
12
+ host: HOST,
13
+ service: 'execute-api',
14
+ path,
15
+ };
16
+ const signedOptions = aws4.sign(requestOptions, creds);
17
+ const signedHeaders = signedOptions.headers;
18
+ const url = `https://${HOST}${path}`;
19
+ options.headers = signedHeaders;
20
+ let response;
21
+ try {
22
+ response = await fetch(url, options);
23
+ }
24
+ catch (error) {
25
+ throw new Error('Error (network?) making the request:', { cause: error });
26
+ }
27
+ const text = await response.text();
28
+ if (!response.ok) {
29
+ if (response.status === 422) {
30
+ throw new KnownException(`Validation error: ${text}`);
31
+ }
32
+ throw new Error(`Received ${response.status} response: ${text}`);
33
+ }
34
+ return text;
35
+ }
36
+ async getCredentials() {
37
+ let identityProvider;
38
+ if (process.env.CI) {
39
+ identityProvider = fromEnv();
40
+ }
41
+ else {
42
+ identityProvider = fromSSO();
43
+ }
44
+ try {
45
+ return await identityProvider();
46
+ }
47
+ catch (error) {
48
+ throw new Error('Error fetching credentials:', { cause: error });
49
+ }
50
+ }
51
+ }
@@ -0,0 +1,3 @@
1
+ export { DevEnvClient } from './DevEnvClient.js';
2
+ export { EnvCtl } from './EnvCtl.js';
3
+ export { HttpClient } from './HttpClient.js';
@@ -0,0 +1,34 @@
1
+ import tseslint from 'typescript-eslint'
2
+ import plugin from '@stylistic/eslint-plugin'
3
+ import importPlugin from 'eslint-plugin-import';
4
+
5
+ export default [
6
+ ...tseslint.configs.recommended,
7
+ plugin.configs['recommended-flat'],
8
+ {
9
+ plugins: {
10
+ import: importPlugin,
11
+ },
12
+ rules: {
13
+ '@stylistic/brace-style': ['error', '1tbs'], // 'else' keyword on the same line as closing brace
14
+ '@stylistic/comma-dangle': 'off', // there are cases when trailing comma desired, and sometimes not
15
+ '@typescript-eslint/no-unused-vars': ['error', { // unused var starting from _ is ok
16
+ 'argsIgnorePattern': '^_',
17
+ 'destructuredArrayIgnorePattern': '^_'
18
+ }],
19
+
20
+ // Enforce Alphabetical Import Order and Merge Duplicate Imports
21
+ 'import/order': ['error', {
22
+ 'alphabetize': {'order': 'asc', 'caseInsensitive': true}
23
+ }],
24
+ 'import/no-duplicates': 'error',
25
+ }
26
+ },
27
+ // Test-specific configuration (glob-based override)
28
+ {
29
+ files: ['**/*.test.ts', '**/*.test.tsx'],
30
+ rules: {
31
+ '@typescript-eslint/no-extra-non-null-assertion': 'off', // allow using !! initially to use with .mock.calls[0]!!
32
+ },
33
+ },
34
+ ];
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@agilecustoms/envctl",
3
+ "description": "node.js CLI client for manage environments",
4
+ "version": "0.1.0",
5
+ "author": "Alex Chekulaev",
6
+ "type": "module",
7
+ "bin": {
8
+ "envctl": "dist/index.js"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/agilecustoms/envctl.git"
13
+ },
14
+ "license": "UNLICENSED",
15
+ "_comment0": "to run via `npx` you must use `name` (with scope) and have at least one entry in `bin`. Key does not matter when run via `npx`",
16
+ "_comment1": "keys become CLI apps (symlinks) when install globally. ex: npm install -g @agilecustoms/envctl; ls $(which alexc-blah) >> lrwxr-xr-x 1 alexc admin 62 Dec 30 14:29 /opt/homebrew/bin/alexc-blah -> ../lib/node_modules/@agilecustoms/envctl-client/dist/index.js",
17
+ "scripts": {
18
+ "lint": "eslint src/*.ts src/**/*.ts test/**/*.ts",
19
+ "lint:fix": "npm run lint -- --fix",
20
+ "test": "vitest run --coverage",
21
+ "build": "tsc",
22
+ "run": "node dist/index.js",
23
+ "run-version": "tsc --sourceMap true; npm run run -- --version",
24
+ "run-register": "tsc --sourceMap true; npm run run -- register --project tt --env alexc --owner laxa1986",
25
+ "run-ephemeral": "tsc --sourceMap true; npm run run -- ephemeral -p tt -e tempenv",
26
+ "run-delete": "tsc --sourceMap true; npm run run -- delete tt-alexc"
27
+ },
28
+ "dependencies": {
29
+ "@aws-sdk/client-sts": "^3.716.0",
30
+ "@aws-sdk/credential-providers": "^3.716.0",
31
+ "aws4": "^1.13.2",
32
+ "commander": "^13.0.0"
33
+ },
34
+ "devDependencies": {
35
+ "@stylistic/eslint-plugin": "^4.2.0",
36
+ "@types/aws4": "^1.11.6",
37
+ "@types/node": "^22.10.2",
38
+ "@vitest/coverage-v8": "^3.0.2",
39
+ "eslint": "^9.22.0",
40
+ "eslint-plugin-import": "^2.31.0",
41
+ "typescript": "^5.7.2",
42
+ "typescript-eslint": "^8.8.1",
43
+ "vitest": "^3.0.2"
44
+ }
45
+ }
@@ -0,0 +1,21 @@
1
+ import { Command } from 'commander'
2
+ import { envCtl } from '../container.js'
3
+ import { wrap } from '../exceptions.js'
4
+
5
+ export function ephemeral(program: Command) {
6
+ program
7
+ .command('ephemeral')
8
+ .description('Create new ephemeral environment')
9
+ .requiredOption('-k, --key <key>', 'Key used to store env state (s3 key in case of AWS). Can be git hash, feature-a or {project-code}-{env-name}')
10
+ .action(wrap(handler))
11
+ }
12
+
13
+ type Options = {
14
+ key: string
15
+ }
16
+
17
+ async function handler(options: object): Promise<string> {
18
+ const { key } = options as Options
19
+
20
+ return await envCtl.createEphemeralEnv(key)
21
+ }
@@ -0,0 +1,15 @@
1
+ import { Command } from 'commander'
2
+ import { envCtl } from '../container.js'
3
+ import { wrap } from '../exceptions.js'
4
+
5
+ export function deleteIt(program: Command) {
6
+ program
7
+ .command('delete')
8
+ .description('Delete a development environment')
9
+ .argument('<string>', 'key used to create/register the environment')
10
+ .action(wrap(handler))
11
+ }
12
+
13
+ async function handler(key: string): Promise<void> {
14
+ await envCtl.delete(key)
15
+ }
@@ -0,0 +1,3 @@
1
+ export { ephemeral } from './createEphemeral.js'
2
+ export { register } from './register.js'
3
+ export { deleteIt } from './delete.js'
@@ -0,0 +1,23 @@
1
+ import { Command } from 'commander'
2
+ import { envCtl } from '../container.js'
3
+ import { wrap } from '../exceptions.js'
4
+
5
+ export function register(program: Command) {
6
+ program
7
+ .command('register')
8
+ .description('Create new or update existing dev environment')
9
+ .requiredOption('-k, --key <key>', 'Key used to store env state (s3 key in case of AWS). Can be git hash, feature-a or {project-code}-{env-name}')
10
+ .requiredOption('-o, --owner <owner>', 'Environment owner (GH username)')
11
+ .action(wrap(handler))
12
+ }
13
+
14
+ type Options = {
15
+ key: string
16
+ owner: string
17
+ }
18
+
19
+ async function handler(options: object): Promise<string> {
20
+ const { key, owner } = options as Options
21
+
22
+ return await envCtl.registerEnv(key, owner)
23
+ }
@@ -0,0 +1,9 @@
1
+ import { STSClient } from '@aws-sdk/client-sts'
2
+ import { DevEnvClient, EnvCtl, HttpClient } from './service/index.js'
3
+
4
+ const stsClient = new STSClient()
5
+ const httpClient = new HttpClient()
6
+ const devEnvClient = new DevEnvClient(httpClient)
7
+ const envCtl = new EnvCtl(stsClient, devEnvClient)
8
+
9
+ export { envCtl }
@@ -0,0 +1,29 @@
1
+ export class KnownException extends Error {
2
+ constructor(message: string) {
3
+ super(message)
4
+ this.name = 'KnownException'
5
+ }
6
+ }
7
+
8
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
9
+ export function wrap(callable: (...args: any[]) => Promise<string | void>): (...args: any[]) => void | Promise<void> {
10
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
11
+ return async (...args: any[]): Promise<void> => {
12
+ let result: string | void
13
+ try {
14
+ result = await callable(...args)
15
+ } catch (error) {
16
+ if (error instanceof KnownException) {
17
+ console.error(error.message)
18
+ } else {
19
+ // print stack trace
20
+ console.error('Unknown error:', error)
21
+ }
22
+ process.exit(1)
23
+ }
24
+ // this is how result is returned back so it can be accessed by calling shell script
25
+ if (result !== undefined) {
26
+ console.log(result)
27
+ }
28
+ }
29
+ }
package/src/index.ts ADDED
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from 'module'
3
+ import { Command } from 'commander'
4
+ import { deleteIt, ephemeral, register } from './commands/index.js'
5
+
6
+ // it is possible to use 'import' to load JSON files, but package.json is level higher, so such trick copies 'src' in /dist :(
7
+ const require = createRequire(import.meta.url) // custom 'require' relative to current module’s file URL
8
+ const { version } = require('../package.json')
9
+
10
+ const program = new Command()
11
+ program
12
+ .name('envctl') // shown when running --help: Usage: envctl [options] [command]
13
+ .description('CLI to manage environments')
14
+ .version(version)
15
+ register(program)
16
+ ephemeral(program)
17
+ deleteIt(program)
18
+
19
+ program.parse(process.argv)
@@ -0,0 +1,4 @@
1
+ export type Environment = {
2
+ account: string
3
+ key: string
4
+ }
@@ -0,0 +1,5 @@
1
+ import type { Environment } from './Environment.js'
2
+
3
+ export type PersonalEnvironment = Environment & {
4
+ owner: string
5
+ }
@@ -0,0 +1,2 @@
1
+ export type { Environment } from './Environment.js'
2
+ export type { PersonalEnvironment } from './PersonalEnvironment.js'
@@ -0,0 +1,34 @@
1
+ import type { Environment, PersonalEnvironment } from '../model/index.js'
2
+ import { HttpClient } from './HttpClient.js'
3
+
4
+ export class DevEnvClient {
5
+ private httpClient: HttpClient
6
+
7
+ constructor(httpClient: HttpClient) {
8
+ this.httpClient = httpClient
9
+ }
10
+
11
+ public async registerEnv(env: PersonalEnvironment): Promise<string> {
12
+ return await this.send('PUT', '/ci/env', env)
13
+ }
14
+
15
+ public async createEphemeralEnv(env: Environment): Promise<string> {
16
+ return await this.send('POST', '/ci/env', env)
17
+ }
18
+
19
+ public async delete(envId: string): Promise<void> {
20
+ await this.httpClient.fetch(`/ci/env/${envId}`, {
21
+ method: 'DELETE'
22
+ })
23
+ }
24
+
25
+ private async send(method: string, path: string, env: Environment): Promise<string> {
26
+ return this.httpClient.fetch(path, {
27
+ method,
28
+ body: JSON.stringify(env),
29
+ headers: {
30
+ 'Content-Type': 'application/json'
31
+ }
32
+ })
33
+ }
34
+ }
@@ -0,0 +1,57 @@
1
+ import { GetCallerIdentityCommand, STSClient } from '@aws-sdk/client-sts'
2
+ import type { GetCallerIdentityResponse } from '@aws-sdk/client-sts'
3
+ import { KnownException } from '../exceptions.js'
4
+ import { DevEnvClient } from './DevEnvClient.js'
5
+
6
+ export class EnvCtl {
7
+ private stsClient: STSClient
8
+ private devEnvClient: DevEnvClient
9
+
10
+ constructor(
11
+ stsClient: STSClient,
12
+ devEnvClient: DevEnvClient
13
+ ) {
14
+ this.stsClient = stsClient
15
+ this.devEnvClient = devEnvClient
16
+ }
17
+
18
+ private async getAccount(): Promise<string> {
19
+ const command = new GetCallerIdentityCommand({})
20
+ let response: GetCallerIdentityResponse
21
+ try {
22
+ response = await this.stsClient.send(command)
23
+ } catch (error) {
24
+ // there is no type to check with instanceof, and also different errors have same .name == 'CredentialsProviderError'
25
+ if (error instanceof Error) {
26
+ const message = error.message
27
+ if (message.startsWith('The SSO session token') || message.startsWith('Token is expired')) {
28
+ throw new KnownException(error.message)
29
+ }
30
+ }
31
+ throw new Error('Error fetching account', { cause: error })
32
+ }
33
+ return response.Account!
34
+ }
35
+
36
+ public async registerEnv(key: string, owner: string): Promise<string> {
37
+ const account = await this.getAccount()
38
+ const env = { account, key, owner }
39
+ return await this.devEnvClient.registerEnv(env)
40
+ }
41
+
42
+ public async createEphemeralEnv(key: string): Promise<string> {
43
+ const account = await this.getAccount()
44
+ const env = { account, key }
45
+ return await this.devEnvClient.createEphemeralEnv(env)
46
+ }
47
+
48
+ /**
49
+ * Here client uses secret knowledge on how id is constructed from account and key
50
+ * Probably it is OK because client and server are tightly coupled
51
+ */
52
+ public async delete(key: string): Promise<void> {
53
+ const account = await this.getAccount()
54
+ const envId = `${account}-${key}`
55
+ return this.devEnvClient.delete(envId)
56
+ }
57
+ }
@@ -0,0 +1,58 @@
1
+ import { fromEnv, fromSSO } from '@aws-sdk/credential-providers'
2
+ import type { AwsCredentialIdentity, AwsCredentialIdentityProvider } from '@smithy/types'
3
+ import aws4 from 'aws4'
4
+ import type { Credentials, Request as Aws4Request } from 'aws4'
5
+ import { KnownException } from '../exceptions.js'
6
+
7
+ const HOST = 'dev-env.maintenance.agilecustoms.com'
8
+
9
+ export class HttpClient {
10
+ public async fetch(path: string, options: RequestInit): Promise<string> {
11
+ const creds: Credentials = await this.getCredentials()
12
+
13
+ const requestOptions: Aws4Request = {
14
+ method: options.method,
15
+ body: options.body as string | undefined,
16
+ headers: options.headers as Record<string, string>,
17
+ host: HOST,
18
+ service: 'execute-api',
19
+ path,
20
+ }
21
+ const signedOptions: Aws4Request = aws4.sign(requestOptions, creds)
22
+ const signedHeaders = signedOptions.headers as Record<string, string>
23
+
24
+ const url = `https://${HOST}${path}`
25
+ options.headers = signedHeaders
26
+
27
+ let response: Response
28
+ try {
29
+ response = await fetch(url, options)
30
+ } catch (error) {
31
+ throw new Error('Error (network?) making the request:', { cause: error })
32
+ }
33
+
34
+ const text = await response.text()
35
+ if (!response.ok) {
36
+ if (response.status === 422) {
37
+ throw new KnownException(`Validation error: ${text}`)
38
+ }
39
+ throw new Error(`Received ${response.status} response: ${text}`)
40
+ }
41
+
42
+ return text
43
+ }
44
+
45
+ private async getCredentials(): Promise<AwsCredentialIdentity> {
46
+ let identityProvider: AwsCredentialIdentityProvider
47
+ if (process.env.CI) {
48
+ identityProvider = fromEnv()
49
+ } else {
50
+ identityProvider = fromSSO()
51
+ }
52
+ try {
53
+ return await identityProvider()
54
+ } catch (error) {
55
+ throw new Error('Error fetching credentials:', { cause: error })
56
+ }
57
+ }
58
+ }
@@ -0,0 +1,3 @@
1
+ export { DevEnvClient } from './DevEnvClient.js'
2
+ export { EnvCtl } from './EnvCtl.js'
3
+ export { HttpClient } from './HttpClient.js'
@@ -0,0 +1,34 @@
1
+ import { STSClient } from '@aws-sdk/client-sts'
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { DevEnvClient, EnvCtl } from '../../src/service'
4
+
5
+ const stsClient = {
6
+ send: vi.fn(),
7
+ }
8
+
9
+ const devEnvClient = {
10
+ registerEnv: vi.fn(),
11
+ }
12
+
13
+ describe('EnvCtl', () => {
14
+ let envCtl: EnvCtl
15
+ beforeEach(() => {
16
+ envCtl = new EnvCtl(
17
+ stsClient as unknown as STSClient,
18
+ devEnvClient as unknown as DevEnvClient
19
+ )
20
+ })
21
+
22
+ describe('registerEnv', () => {
23
+ it('should call stsClient to get account and then call devEnvClient to register env', async () => {
24
+ stsClient.send.mockResolvedValue({ Account: 'testAccount' })
25
+ devEnvClient.registerEnv.mockResolvedValue('testEnvId')
26
+
27
+ const result = await envCtl.registerEnv('env', 'owner')
28
+
29
+ expect(result).toBe('testEnvId')
30
+ expect(stsClient.send).toHaveBeenCalled()
31
+ expect(devEnvClient.registerEnv).toHaveBeenCalled()
32
+ })
33
+ })
34
+ })
package/tsconfig.json ADDED
@@ -0,0 +1,23 @@
1
+ // https://www.typescriptlang.org/tsconfig
2
+ {
3
+ "include": [
4
+ "src"
5
+ ],
6
+ // Runtime is Node 23 (latest as Jan 2025), to be run in CI (action/setup-node) and local machines
7
+ "compilerOptions": {
8
+ // MAIN SETTINGS (based on runtime)
9
+ "target": "es2023", // highest available, fully supported by Node 23
10
+ "module": "nodenext", // preferred for modern Node projects
11
+ "outDir": "dist", // standard output folder for all emitted files
12
+
13
+ // DEFAULTS that are always "good to have" - expect to be the same for all projects
14
+ "allowJs": false, // no need to compile JS, all is TS
15
+ "noUncheckedIndexedAccess": true,
16
+ "removeComments": true,
17
+ "skipLibCheck": true, // skip type checking of declaration files (.d.ts) - allows quick hot reload
18
+ "strict": true, // enable all strict type-checking options
19
+ "verbatimModuleSyntax": true, // force using 'import type' for types
20
+
21
+ // ADDITIONAL SETTINGS (vary per project)
22
+ }
23
+ }
@@ -0,0 +1,19 @@
1
+ import { coverageConfigDefaults, defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ coverage: {
6
+ exclude: [
7
+ '**/index.ts',
8
+ 'src/container.ts',
9
+ 'src/model/**',
10
+ ...coverageConfigDefaults.exclude
11
+ ],
12
+ reporter: ['text'], // other: 'html', 'clover', 'json'
13
+ thresholds: {
14
+ lines: 26,
15
+ branches: 44,
16
+ }
17
+ }
18
+ }
19
+ })