@tahminator/pipeline 1.0.59 → 1.0.60

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 CHANGED
@@ -42,9 +42,30 @@ import {
42
42
  SonarScannerClient,
43
43
  Utils,
44
44
  VersioningClient,
45
+ LocalPostgresClient,
46
+ LocalRedisClient,
45
47
  } from "@tahminator/pipeline";
46
48
  ```
47
49
 
50
+ Jump to client documentation
51
+
52
+ <!-- toc -->
53
+
54
+ - [`GitHubClient`](#githubclient)
55
+ - [`DockerClient`](#dockerclient)
56
+ - [`NPMClient`](#npmclient)
57
+ - [`SonarScannerClient`](#sonarscannerclient)
58
+ - [`Utils`](#utils)
59
+ - [`EnvClient`](#envclient)
60
+ - [`PulumiClient`](#pulumiclient)
61
+ - [`VersioningClient`](#versioningclient)
62
+ - [`LocalPostgresClient`](#localpostgresclient)
63
+ - [`LocalRedisClient`](#localredisclient)
64
+
65
+ <!-- tocstop -->
66
+
67
+ <!-- if toc does not update automatically, `markdown-toc -i README.md` -->
68
+
48
69
  > [!NOTE]
49
70
  > While there is documentation below, each API should be relatively well commented and be more up to date than what is seen below.
50
71
 
@@ -328,3 +349,65 @@ if (!Utils.SemVer.validate(beta)) throw new Error("invalid beta version");
328
349
 
329
350
  await versioning.update(beta);
330
351
  ```
352
+
353
+ ### `LocalPostgresClient`
354
+
355
+ Spin up a disposable local Postgres instance via Docker. Useful for running migrations or acceptance tests against a real database in CI / local dev.
356
+
357
+ Each call to `create()` gets a unique container name and a Docker-assigned host port, so multiple instances can coexist without conflict.
358
+
359
+ ```ts
360
+ import { $ } from "bun";
361
+
362
+ async function main() {
363
+ await using pgClient = await LocalPostgresClient.create({
364
+ database: "instalock-server-acceptance",
365
+ });
366
+
367
+ // get credentials
368
+ const { database, host, port, password, user } = pgClient.state;
369
+
370
+ await $.env({
371
+ ...process.env,
372
+ DB_HOST: host,
373
+ DB_PORT: String(port),
374
+ DB_NAME: database,
375
+ DB_USERNAME: user,
376
+ DB_PASSWORD: password,
377
+ })`just migrate`;
378
+
379
+ // `pgClient` will automatically be cleaned up when `main` returns.
380
+ // Set DEBUG=true in the env to dump container logs on cleanup.
381
+ }
382
+
383
+ await main();
384
+ ```
385
+
386
+ ### `LocalRedisClient`
387
+
388
+ Spin up a disposable local Redis instance via Docker.
389
+
390
+ Each call to `create()` gets a unique container name and a Docker-assigned host port, so multiple instances can coexist without conflict.
391
+
392
+ ```ts
393
+ import { $ } from "bun";
394
+
395
+ async function main() {
396
+ await using redisClient = await LocalRedisClient.create();
397
+
398
+ // get credentials
399
+ const { port, password, host } = redisClient.state;
400
+
401
+ await $.env({
402
+ ...process.env,
403
+ REDIS_HOST: host,
404
+ REDIS_PORT: String(port),
405
+ REDIS_PASSWORD: password,
406
+ })`./gradlew bootRun`;
407
+
408
+ // `redisClient` will automatically be cleaned up when `main` returns.
409
+ // Set DEBUG=true in the env to dump container logs on cleanup.
410
+ }
411
+
412
+ await main();
413
+ ```
package/dist/index.d.ts CHANGED
@@ -5,5 +5,7 @@ export * from "./sonar";
5
5
  export * from "./pulumi";
6
6
  export * from "./types";
7
7
  export * from "./env";
8
+ export * from "./postgres";
9
+ export * from "./redis";
8
10
  export * from "./utils";
9
11
  export * from "./versioning";
package/dist/index.js CHANGED
@@ -5,5 +5,7 @@ export * from "./sonar";
5
5
  export * from "./pulumi";
6
6
  export * from "./types";
7
7
  export * from "./env";
8
+ export * from "./postgres";
9
+ export * from "./redis";
8
10
  export * from "./utils";
9
11
  export * from "./versioning";
@@ -42,7 +42,7 @@ async function main() {
42
42
  message: `
43
43
  ## Test Version Uploaded
44
44
 
45
- Uploaded \`${betaVersion}\` to NPM. View version on NPM registry [here](https://www.npmjs.com/package/@tahminator/pipeline/v/${betaVersion}).
45
+ Uploaded \`@tahminator/pipeline@${betaVersion}\` to NPM. View version on NPM registry [here](https://www.npmjs.com/package/@tahminator/pipeline/v/${betaVersion}).
46
46
  `,
47
47
  });
48
48
  }
@@ -0,0 +1,37 @@
1
+ import type { PostgresInternalState, PostgresState } from "./types";
2
+ export declare class LocalPostgresClient {
3
+ private readonly iState;
4
+ private constructor();
5
+ /**
6
+ * Spin up a local Postgres instance via Docker.
7
+ *
8
+ * @example
9
+ * ```ts
10
+ async function main() {
11
+ await using pgClient = await LocalPostgresClient.create({
12
+ database: "instalock-server-acceptance",
13
+ });
14
+
15
+ // get credentials
16
+ const { database, host, port, password, user } = pgClient.state;
17
+
18
+ $.env({
19
+ ...process.env,
20
+ DB_HOST: host,
21
+ DB_PORT: String(port),
22
+ DB_NAME: database,
23
+ DB_USERNAME: user,
24
+ DB_PASSWORD: password,
25
+ })`just migrate`
26
+
27
+ // `pgClient` will be automatically cleaned up at the end of the scope.
28
+ }
29
+ * ```
30
+ */
31
+ static create(state: PostgresState): Promise<LocalPostgresClient>;
32
+ private static launch;
33
+ private static waitUntilReady;
34
+ get state(): PostgresInternalState;
35
+ [Symbol.asyncDispose](): Promise<void>;
36
+ cleanup(): Promise<void>;
37
+ }
@@ -0,0 +1,107 @@
1
+ import { $, randomUUIDv7 } from "bun";
2
+ import { Utils } from "../utils";
3
+ export class LocalPostgresClient {
4
+ iState;
5
+ constructor(iState) {
6
+ this.iState = iState;
7
+ }
8
+ /**
9
+ * Spin up a local Postgres instance via Docker.
10
+ *
11
+ * @example
12
+ * ```ts
13
+ async function main() {
14
+ await using pgClient = await LocalPostgresClient.create({
15
+ database: "instalock-server-acceptance",
16
+ });
17
+
18
+ // get credentials
19
+ const { database, host, port, password, user } = pgClient.state;
20
+
21
+ $.env({
22
+ ...process.env,
23
+ DB_HOST: host,
24
+ DB_PORT: String(port),
25
+ DB_NAME: database,
26
+ DB_USERNAME: user,
27
+ DB_PASSWORD: password,
28
+ })`just migrate`
29
+
30
+ // `pgClient` will be automatically cleaned up at the end of the scope.
31
+ }
32
+ * ```
33
+ */
34
+ static async create(state) {
35
+ const iState = {
36
+ ...state,
37
+ user: "postgres",
38
+ password: "postgres",
39
+ port: 5432,
40
+ host: "127.0.0.1",
41
+ dockerName: `local-db-${randomUUIDv7()}`,
42
+ };
43
+ const hostPort = await this.launch(iState);
44
+ iState.port = hostPort;
45
+ await this.waitUntilReady(iState);
46
+ return new this(iState);
47
+ }
48
+ static async launch(iState) {
49
+ await $ `docker run -d \
50
+ --name ${iState.dockerName} \
51
+ -e POSTGRES_USER=${iState.user} \
52
+ -e POSTGRES_PASSWORD=${iState.password} \
53
+ -e POSTGRES_DB=${iState.database} \
54
+ -p 5432 \
55
+ mirror.gcr.io/library/postgres:16-alpine`;
56
+ const raw = (await $ `docker port ${iState.dockerName} 5432/tcp`.text()).trim();
57
+ const match = raw.match(/:(\d+)/);
58
+ if (!match) {
59
+ throw new Error(`Could not parse host port from: ${raw}`);
60
+ }
61
+ return Number(match[1]);
62
+ }
63
+ static async waitUntilReady(iState) {
64
+ console.log(`Waiting for ${iState.dockerName} to become ready.`);
65
+ const attempts = 30;
66
+ const ready = await Utils.waitUntil({
67
+ attempts,
68
+ intervalMs: 2000,
69
+ predicate: async (attempt) => {
70
+ const check = await $ `docker exec ${iState.dockerName} pg_isready -U ${iState.user}`
71
+ .quiet()
72
+ .nothrow();
73
+ if (check.exitCode === 0) {
74
+ return true;
75
+ }
76
+ console.log(`Waiting for ${iState.dockerName}... (${attempt}/${attempts})`);
77
+ return false;
78
+ },
79
+ });
80
+ if (!ready) {
81
+ const msg = `${iState.dockerName} failed to launch`;
82
+ console.error(msg);
83
+ throw new Error(msg);
84
+ }
85
+ console.log(`${iState.dockerName} is ready`);
86
+ }
87
+ get state() {
88
+ return this.iState;
89
+ }
90
+ async [Symbol.asyncDispose]() {
91
+ await this.cleanup();
92
+ }
93
+ async cleanup() {
94
+ console.log(`Stopping and removing ${this.iState.dockerName} container...`);
95
+ if (Utils.Log.isDebug) {
96
+ console.log(Utils.Colors.brightMagenta(`=== ${this.iState.dockerName} LOGS ===`));
97
+ const logs = await $ `docker logs ${this.iState.dockerName}`.text();
98
+ logs
99
+ .split("\n")
100
+ .filter((s) => s.length > 0)
101
+ .forEach((line) => console.log(Utils.Colors.brightMagenta(line)));
102
+ console.log(Utils.Colors.brightMagenta(`=== ${this.iState.dockerName} LOGS END ===`));
103
+ }
104
+ await $ `docker stop ${this.iState.dockerName}`.quiet().nothrow();
105
+ await $ `docker rm ${this.iState.dockerName}`.quiet().nothrow();
106
+ }
107
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./client";
2
+ export * from "./types";
@@ -0,0 +1,2 @@
1
+ export * from "./client";
2
+ export * from "./types";
@@ -0,0 +1,10 @@
1
+ export interface PostgresState {
2
+ database: string;
3
+ }
4
+ export interface PostgresInternalState extends PostgresState {
5
+ port: number;
6
+ host: string;
7
+ user: string;
8
+ password: string;
9
+ dockerName: string;
10
+ }
File without changes
@@ -0,0 +1,37 @@
1
+ import type { RedisInternalState, RedisState } from "./types";
2
+ export declare class LocalRedisClient {
3
+ private readonly iState;
4
+ private constructor();
5
+ /**
6
+ * Spin up a local Redis instance via Docker.
7
+ *
8
+ * @example
9
+ * ```ts
10
+ async function main() {
11
+ await using redisClient = await LocalRedisClient.create();
12
+
13
+ // get credentials
14
+ const {
15
+ port,
16
+ password,
17
+ host,
18
+ } = redisClient.state;
19
+
20
+ $.env({
21
+ ...process.env,
22
+ REDISHOST: host,
23
+ REDIS_PORT: String(port),
24
+ REDIS_PASSWORD: password,
25
+ })`./gradlew bootRun`
26
+
27
+ // `redisClient` will be automatically cleaned up at the end of the scope.
28
+ }
29
+ * ```
30
+ */
31
+ static create(state?: RedisState): Promise<LocalRedisClient>;
32
+ private static launch;
33
+ private static waitUntilReady;
34
+ get state(): RedisInternalState;
35
+ [Symbol.asyncDispose](): Promise<void>;
36
+ cleanup(): Promise<void>;
37
+ }
@@ -0,0 +1,104 @@
1
+ import { $, randomUUIDv7 } from "bun";
2
+ import { Utils } from "../utils";
3
+ export class LocalRedisClient {
4
+ iState;
5
+ constructor(iState) {
6
+ this.iState = iState;
7
+ }
8
+ /**
9
+ * Spin up a local Redis instance via Docker.
10
+ *
11
+ * @example
12
+ * ```ts
13
+ async function main() {
14
+ await using redisClient = await LocalRedisClient.create();
15
+
16
+ // get credentials
17
+ const {
18
+ port,
19
+ password,
20
+ host,
21
+ } = redisClient.state;
22
+
23
+ $.env({
24
+ ...process.env,
25
+ REDISHOST: host,
26
+ REDIS_PORT: String(port),
27
+ REDIS_PASSWORD: password,
28
+ })`./gradlew bootRun`
29
+
30
+ // `redisClient` will be automatically cleaned up at the end of the scope.
31
+ }
32
+ * ```
33
+ */
34
+ static async create(state = {}) {
35
+ const iState = {
36
+ ...state,
37
+ password: "redis",
38
+ port: 6379,
39
+ host: "127.0.0.1",
40
+ dockerName: `local-redis-${randomUUIDv7()}`,
41
+ };
42
+ const hostPort = await this.launch(iState);
43
+ iState.port = hostPort;
44
+ await this.waitUntilReady(iState);
45
+ return new this(iState);
46
+ }
47
+ static async launch(iState) {
48
+ await $ `docker run -d \
49
+ --name ${iState.dockerName} \
50
+ -p 6379 \
51
+ mirror.gcr.io/library/redis:7-alpine \
52
+ redis-server --requirepass ${iState.password}`;
53
+ const raw = (await $ `docker port ${iState.dockerName} 6379/tcp`.text()).trim();
54
+ const match = raw.match(/:(\d+)/);
55
+ if (!match) {
56
+ throw new Error(`Could not parse host port from: ${raw}`);
57
+ }
58
+ return Number(match[1]);
59
+ }
60
+ static async waitUntilReady(iState) {
61
+ console.log(`Waiting for ${iState.dockerName} to become ready.`);
62
+ const attempts = 30;
63
+ const ready = await Utils.waitUntil({
64
+ attempts,
65
+ intervalMs: 2000,
66
+ predicate: async (attempt) => {
67
+ const check = await $ `docker exec ${iState.dockerName} redis-cli -a ${iState.password} ping`
68
+ .quiet()
69
+ .nothrow();
70
+ if (check.exitCode === 0) {
71
+ return true;
72
+ }
73
+ console.log(`Waiting for ${iState.dockerName}... (${attempt}/${attempts})`);
74
+ return false;
75
+ },
76
+ });
77
+ if (!ready) {
78
+ const msg = `${iState.dockerName} failed to launch`;
79
+ console.error(msg);
80
+ throw new Error(msg);
81
+ }
82
+ console.log(`${iState.dockerName} is ready`);
83
+ }
84
+ get state() {
85
+ return this.iState;
86
+ }
87
+ async [Symbol.asyncDispose]() {
88
+ await this.cleanup();
89
+ }
90
+ async cleanup() {
91
+ console.log(`Stopping and removing ${this.iState.dockerName} container...`);
92
+ if (Utils.Log.isDebug) {
93
+ console.log(Utils.Colors.brightMagenta(`=== ${this.iState.dockerName} LOGS ===`));
94
+ const logs = await $ `docker logs ${this.iState.dockerName}`.text();
95
+ logs
96
+ .split("\n")
97
+ .filter((s) => s.length > 0)
98
+ .forEach((line) => console.log(Utils.Colors.brightMagenta(line)));
99
+ console.log(Utils.Colors.brightMagenta(`=== ${this.iState.dockerName} LOGS END ===`));
100
+ }
101
+ await $ `docker stop ${this.iState.dockerName}`.quiet().nothrow();
102
+ await $ `docker rm ${this.iState.dockerName}`.quiet().nothrow();
103
+ }
104
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./client";
2
+ export * from "./types";
@@ -0,0 +1,2 @@
1
+ export * from "./client";
2
+ export * from "./types";
@@ -0,0 +1,8 @@
1
+ export interface RedisState {
2
+ }
3
+ export interface RedisInternalState extends RedisState {
4
+ port: number;
5
+ host: string;
6
+ password: string;
7
+ dockerName: string;
8
+ }
File without changes
@@ -4,6 +4,7 @@ import { Colors } from "./colors";
4
4
  import { Log } from "./log";
5
5
  import { SemVer } from "./semver";
6
6
  import { generateShortId } from "./short";
7
+ import { waitUntil } from "./wait";
7
8
  export declare class Utils {
8
9
  static Colors: typeof Colors;
9
10
  static Log: typeof Log;
@@ -11,4 +12,5 @@ export declare class Utils {
11
12
  static generateShortId(...args: Parameters<typeof generateShortId>): string;
12
13
  static isCmdAvailable(...args: Parameters<typeof isCmdAvailable>): Promise<boolean>;
13
14
  static decodeBase64EncodedString(...args: Parameters<typeof decodeBase64EncodedString>): Promise<string>;
15
+ static waitUntil(...args: Parameters<typeof waitUntil>): Promise<boolean>;
14
16
  }
@@ -4,6 +4,7 @@ import { Colors } from "./colors";
4
4
  import { Log } from "./log";
5
5
  import { SemVer } from "./semver";
6
6
  import { generateShortId } from "./short";
7
+ import { waitUntil } from "./wait";
7
8
  export class Utils {
8
9
  // hoist
9
10
  static Colors = Colors;
@@ -18,4 +19,7 @@ export class Utils {
18
19
  static async decodeBase64EncodedString(...args) {
19
20
  return decodeBase64EncodedString(...args);
20
21
  }
22
+ static async waitUntil(...args) {
23
+ return waitUntil(...args);
24
+ }
21
25
  }
@@ -0,0 +1,5 @@
1
+ export declare function waitUntil({ predicate, attempts, intervalMs, }: {
2
+ predicate: (attempt: number) => boolean | Promise<boolean>;
3
+ attempts?: number;
4
+ intervalMs?: number;
5
+ }): Promise<boolean>;
@@ -0,0 +1,9 @@
1
+ export async function waitUntil({ predicate, attempts = 30, intervalMs = 2000, }) {
2
+ for (let i = 1; i <= attempts; i++) {
3
+ if (await predicate(i)) {
4
+ return true;
5
+ }
6
+ await Bun.sleep(intervalMs);
7
+ }
8
+ return false;
9
+ }
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "type": "module",
4
4
  "author": "Tahmid Ahmed",
5
5
  "description": "A collection of Bun shell scripts that can be re-used in various CICD pipelines.",
6
- "version": "1.0.59",
6
+ "version": "1.0.60",
7
7
  "repository": {
8
8
  "url": "git+https://github.com/tahminator/pipeline.git"
9
9
  },