@travetto/cli 5.0.10 → 5.0.12
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 +61 -2
- package/__index__.ts +1 -0
- package/package.json +3 -3
- package/src/error.ts +1 -1
- package/src/scm.ts +1 -1
- package/src/service.ts +157 -0
- package/support/cli.service.ts +71 -0
package/README.md
CHANGED
|
@@ -171,7 +171,7 @@ Options:
|
|
|
171
171
|
$ trv basic:arg 20
|
|
172
172
|
|
|
173
173
|
Execution failed:
|
|
174
|
-
* Argument volume is
|
|
174
|
+
* Argument volume is greater than (10)
|
|
175
175
|
|
|
176
176
|
Usage: basic:arg [options] [volume:number]
|
|
177
177
|
|
|
@@ -234,7 +234,7 @@ $ trv basic:arglist 10 5 3 9 8 1
|
|
|
234
234
|
$ trv basic:arglist 10 5 3 9 20 1
|
|
235
235
|
|
|
236
236
|
Execution failed:
|
|
237
|
-
* Argument volumes[4] is
|
|
237
|
+
* Argument volumes[4] is greater than (10)
|
|
238
238
|
|
|
239
239
|
Usage: basic:arglist [options] <volumes...:number>
|
|
240
240
|
|
|
@@ -533,3 +533,62 @@ async validate(): Promise<CliValidationError | undefined> {
|
|
|
533
533
|
}
|
|
534
534
|
}
|
|
535
535
|
```
|
|
536
|
+
|
|
537
|
+
## CLI - service
|
|
538
|
+
The module provides the ability to start/stop/restart services as [docker](https://www.docker.com/community-edition) containers. This is meant to be used for development purposes, to minimize the effort of getting an application up and running. Services can be targeted individually or handled as a group.
|
|
539
|
+
|
|
540
|
+
**Terminal: Command Service**
|
|
541
|
+
```bash
|
|
542
|
+
$ trv service --help
|
|
543
|
+
|
|
544
|
+
Usage: service [options] <action:restart|start|status|stop> [services...:string]
|
|
545
|
+
|
|
546
|
+
Options:
|
|
547
|
+
-h, --help display help for command
|
|
548
|
+
|
|
549
|
+
Available Services
|
|
550
|
+
--------------------
|
|
551
|
+
* dynamodb@2.0.0
|
|
552
|
+
* elasticsearch@8.9.1
|
|
553
|
+
* firestore@latest
|
|
554
|
+
* mongodb@7.0
|
|
555
|
+
* mysql@8.0
|
|
556
|
+
* postgresql@15.4
|
|
557
|
+
* redis@7.2
|
|
558
|
+
* s3@3.1.0
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
A sample of all services available to the entire framework:
|
|
562
|
+
|
|
563
|
+
**Terminal: All Services**
|
|
564
|
+
```bash
|
|
565
|
+
$ trv service status
|
|
566
|
+
|
|
567
|
+
Service Version Status
|
|
568
|
+
-------------------------------------------------
|
|
569
|
+
dynamodb 2.0.0 Running 93af422e793a
|
|
570
|
+
elasticsearch 8.9.1 Running ed76ee063d13
|
|
571
|
+
firestore latest Running feec2e5e95b4
|
|
572
|
+
mongodb 7.0 Running 5513eba6734e
|
|
573
|
+
mysql 8.0 Running 307bc66d442a
|
|
574
|
+
postgresql 15.4 Running e78291e71040
|
|
575
|
+
redis 7.2 Running 77ba279b4e30
|
|
576
|
+
s3 3.1.0 Running fdacfc55b9e3
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
### Defining new Services
|
|
580
|
+
The services are defined as plain typescript files within the framework and can easily be extended:
|
|
581
|
+
|
|
582
|
+
**Code: Sample Service Definition**
|
|
583
|
+
```typescript
|
|
584
|
+
import type { ServiceDescriptor } from '@travetto/cli';
|
|
585
|
+
|
|
586
|
+
const version = process.env.MONGO_VERSION ?? '7.0';
|
|
587
|
+
|
|
588
|
+
export const service: ServiceDescriptor = {
|
|
589
|
+
name: 'mongodb',
|
|
590
|
+
version,
|
|
591
|
+
port: 27017,
|
|
592
|
+
image: `mongo:${version}`
|
|
593
|
+
};
|
|
594
|
+
```
|
package/__index__.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@travetto/cli",
|
|
3
|
-
"version": "5.0.
|
|
3
|
+
"version": "5.0.12",
|
|
4
4
|
"description": "CLI infrastructure for Travetto framework",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -28,8 +28,8 @@
|
|
|
28
28
|
"directory": "module/cli"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@travetto/schema": "^5.0.
|
|
32
|
-
"@travetto/terminal": "^5.0.
|
|
31
|
+
"@travetto/schema": "^5.0.11",
|
|
32
|
+
"@travetto/terminal": "^5.0.11"
|
|
33
33
|
},
|
|
34
34
|
"travetto": {
|
|
35
35
|
"displayName": "Command Line Interface",
|
package/src/error.ts
CHANGED
|
@@ -54,7 +54,7 @@ export class CliValidationResultError extends AppError<{ errors: CliValidationEr
|
|
|
54
54
|
command: CliCommandShape;
|
|
55
55
|
|
|
56
56
|
constructor(command: CliCommandShape, errors: CliValidationError[]) {
|
|
57
|
-
super('',
|
|
57
|
+
super('', { details: { errors } });
|
|
58
58
|
this.command = command;
|
|
59
59
|
}
|
|
60
60
|
}
|
package/src/scm.ts
CHANGED
|
@@ -50,7 +50,7 @@ export class CliScmUtil {
|
|
|
50
50
|
const ws = Runtime.workspace.path;
|
|
51
51
|
const res = await ExecUtil.getResult(spawn('git', ['diff', '--name-only', `${fromHash}..${toHash}`, ':!**/DOC.*', ':!**/README.*'], { cwd: ws }), { catch: true });
|
|
52
52
|
if (!res.valid) {
|
|
53
|
-
throw new AppError('Unable to detect changes between', 'data', { fromHash, toHash, output: (res.stderr || res.stdout) });
|
|
53
|
+
throw new AppError('Unable to detect changes between', { category: 'data', details: { fromHash, toHash, output: (res.stderr || res.stdout) } });
|
|
54
54
|
}
|
|
55
55
|
const out = new Set<string>();
|
|
56
56
|
for (const line of res.stdout.split(/\n/g)) {
|
package/src/service.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import rl from 'node:readline/promises';
|
|
4
|
+
import net from 'node:net';
|
|
5
|
+
|
|
6
|
+
import { ExecUtil, TimeUtil, Util } from '@travetto/runtime';
|
|
7
|
+
|
|
8
|
+
const ports = (val: number | `${number}:${number}`): [number, number] =>
|
|
9
|
+
typeof val === 'number' ?
|
|
10
|
+
[val, val] :
|
|
11
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
12
|
+
val.split(':').map(x => parseInt(x, 10)) as [number, number];
|
|
13
|
+
|
|
14
|
+
type BodyCheck = (body: string) => boolean;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* This represents the schema for defined services
|
|
18
|
+
*/
|
|
19
|
+
export interface ServiceDescriptor {
|
|
20
|
+
name: string;
|
|
21
|
+
version: string | number;
|
|
22
|
+
port?: number | `${number}:${number}`;
|
|
23
|
+
privileged?: boolean;
|
|
24
|
+
image: string;
|
|
25
|
+
args?: string[];
|
|
26
|
+
ready?: { url: string, test?: BodyCheck };
|
|
27
|
+
volumes?: Record<string, string>;
|
|
28
|
+
env?: Record<string, string>;
|
|
29
|
+
startupTimeout?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type ServiceAction = 'start' | 'stop' | 'status' | 'restart';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Service runner
|
|
36
|
+
*/
|
|
37
|
+
export class ServiceRunner {
|
|
38
|
+
|
|
39
|
+
constructor(public svc: ServiceDescriptor) { }
|
|
40
|
+
|
|
41
|
+
async #isRunning(full = false): Promise<boolean> {
|
|
42
|
+
const port = ports(this.svc.port!)[0];
|
|
43
|
+
const start = Date.now();
|
|
44
|
+
const timeoutMs = TimeUtil.asMillis(full ? this.svc.startupTimeout ?? 5000 : 100);
|
|
45
|
+
while ((Date.now() - start) < timeoutMs) {
|
|
46
|
+
try {
|
|
47
|
+
const sock = net.createConnection(port, 'localhost');
|
|
48
|
+
await new Promise<void>((res, rej) =>
|
|
49
|
+
sock.on('connect', res).on('timeout', rej).on('error', rej)
|
|
50
|
+
).finally(() => sock.destroy());
|
|
51
|
+
|
|
52
|
+
if (!this.svc.ready?.url || !full) {
|
|
53
|
+
return true;
|
|
54
|
+
} else {
|
|
55
|
+
const req = await fetch(this.svc.ready.url, { method: 'GET' });
|
|
56
|
+
const text = await req.text();
|
|
57
|
+
if (req.ok && (this.svc.ready.test?.(text) ?? true)) {
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
} catch {
|
|
62
|
+
await Util.blockingTimeout(50);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async #hasImage(): Promise<boolean> {
|
|
70
|
+
const result = await ExecUtil.getResult(spawn('docker', ['image', 'inspect', this.svc.image], { shell: false }), { catch: true });
|
|
71
|
+
return result.valid;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async * #pullImage(): AsyncIterable<string> {
|
|
75
|
+
const proc = spawn('docker', ['pull', this.svc.image], { stdio: [0, 'pipe', 'pipe'] });
|
|
76
|
+
yield* rl.createInterface(proc.stdout!);
|
|
77
|
+
await ExecUtil.getResult(proc);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async #startContainer(): Promise<string> {
|
|
81
|
+
const args = [
|
|
82
|
+
'run',
|
|
83
|
+
'--rm',
|
|
84
|
+
'--detach',
|
|
85
|
+
...this.svc.privileged ? ['--privileged'] : [],
|
|
86
|
+
'--label', `trv-${this.svc.name}`,
|
|
87
|
+
...Object.entries(this.svc.env ?? {}).flatMap(([k, v]) => ['--env', `${k}=${v}`]),
|
|
88
|
+
...this.svc.port ? ['-p', ports(this.svc.port).join(':')] : [],
|
|
89
|
+
...Object.entries(this.svc.volumes ?? {}).flatMap(([k, v]) => ['--volume', `${k}:${v}`]),
|
|
90
|
+
this.svc.image,
|
|
91
|
+
...this.svc.args ?? [],
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
for (const item of Object.keys(this.svc.volumes ?? {})) {
|
|
95
|
+
await fs.mkdir(item, { recursive: true });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return (await ExecUtil.getResult(spawn('docker', args, { shell: false, stdio: [0, 'pipe', 2] }))).stdout;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async #getContainerId(): Promise<string | undefined> {
|
|
102
|
+
return (await ExecUtil.getResult(spawn('docker', ['ps', '-q', '--filter', `label=trv-${this.svc.name}`], { shell: false }))).stdout.trim();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async #killContainer(cid: string): Promise<void> {
|
|
106
|
+
await ExecUtil.getResult(spawn('docker', ['kill', cid], { shell: false }));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async * action(op: ServiceAction): AsyncIterable<['success' | 'failure' | 'message', string]> {
|
|
110
|
+
try {
|
|
111
|
+
const cid = await this.#getContainerId();
|
|
112
|
+
const port = this.svc.port ? ports(this.svc.port)[0] : 0;
|
|
113
|
+
const running = !!cid && (!port || await this.#isRunning());
|
|
114
|
+
|
|
115
|
+
if (running && !cid) { // We don't own
|
|
116
|
+
return yield [op === 'status' ? 'message' : 'failure', 'Running but not managed'];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (op === 'status') {
|
|
120
|
+
return yield !cid ? ['message', 'Not running'] : ['success', `Running ${cid}`];
|
|
121
|
+
} else if (op === 'start' && running) {
|
|
122
|
+
return yield ['message', 'Skipping, already running'];
|
|
123
|
+
} else if (op === 'stop' && !running) {
|
|
124
|
+
return yield ['message', 'Skipping, already stopped'];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (running && (op === 'restart' || op === 'stop')) {
|
|
128
|
+
yield ['message', 'Stopping'];
|
|
129
|
+
await this.#killContainer(cid);
|
|
130
|
+
yield ['success', 'Stopped'];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (op === 'restart' || op === 'start') {
|
|
134
|
+
if (!await this.#hasImage()) {
|
|
135
|
+
yield ['message', 'Starting image download'];
|
|
136
|
+
for await (const line of await this.#pullImage()) {
|
|
137
|
+
yield ['message', `Downloading: ${line}`];
|
|
138
|
+
}
|
|
139
|
+
yield ['message', 'Image download complete'];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
yield ['message', 'Starting'];
|
|
143
|
+
const out = await this.#startContainer();
|
|
144
|
+
|
|
145
|
+
if (port) {
|
|
146
|
+
yield ['message', `Waiting for ${this.svc.ready?.url ?? 'container'}...`];
|
|
147
|
+
if (!await this.#isRunning(true)) {
|
|
148
|
+
yield ['failure', 'Failed to start service correctly'];
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
yield ['success', `Started ${out.substring(0, 12)}`];
|
|
152
|
+
}
|
|
153
|
+
} catch {
|
|
154
|
+
yield ['failure', 'Failed to start'];
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { CliCommandShape, CliCommand, cliTpl, CliValidationError } from '@travetto/cli';
|
|
2
|
+
import { Terminal } from '@travetto/terminal';
|
|
3
|
+
import { AsyncQueue, Runtime, RuntimeIndex, Util } from '@travetto/runtime';
|
|
4
|
+
|
|
5
|
+
import { ServiceRunner, ServiceDescriptor, ServiceAction } from '../src/service';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Allows for running services
|
|
9
|
+
*/
|
|
10
|
+
@CliCommand()
|
|
11
|
+
export class CliServiceCommand implements CliCommandShape {
|
|
12
|
+
|
|
13
|
+
async #getServices(services: string[]): Promise<ServiceDescriptor[]> {
|
|
14
|
+
return (await Promise.all(
|
|
15
|
+
RuntimeIndex.find({
|
|
16
|
+
module: m => m.roles.includes('std'),
|
|
17
|
+
folder: f => f === 'support',
|
|
18
|
+
file: f => /support\/service[.]/.test(f.sourceFile)
|
|
19
|
+
})
|
|
20
|
+
.map(x => Runtime.importFrom<{ service: ServiceDescriptor }>(x.import).then(v => v.service))
|
|
21
|
+
))
|
|
22
|
+
.filter(x => !!x)
|
|
23
|
+
.filter(x => services?.length ? services.includes(x.name) : true)
|
|
24
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async validate(action: ServiceAction, services: string[]): Promise<CliValidationError | undefined> {
|
|
28
|
+
const all = await this.#getServices(services);
|
|
29
|
+
|
|
30
|
+
if (!all.length) {
|
|
31
|
+
return { message: 'No services found' };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async help(): Promise<string[]> {
|
|
36
|
+
const all = await this.#getServices([]);
|
|
37
|
+
return [
|
|
38
|
+
cliTpl`${{ title: 'Available Services' }}`,
|
|
39
|
+
'-'.repeat(20),
|
|
40
|
+
...all.map(x => cliTpl` * ${{ identifier: x.name }}@${{ type: x.version }}`)
|
|
41
|
+
];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async main(action: ServiceAction, services: string[] = []): Promise<void> {
|
|
45
|
+
const all = await this.#getServices(services);
|
|
46
|
+
const maxName = Math.max(...all.map(x => x.name.length), 'Service'.length) + 3;
|
|
47
|
+
const maxVersion = Math.max(...all.map(x => `${x.version}`.length), 'Version'.length) + 3;
|
|
48
|
+
const maxStatus = 20;
|
|
49
|
+
const q = new AsyncQueue<{ idx: number, text: string, done?: boolean }>();
|
|
50
|
+
|
|
51
|
+
const jobs = all.map(async (v, i) => {
|
|
52
|
+
const identifier = v.name.padEnd(maxName);
|
|
53
|
+
const type = `${v.version}`.padStart(maxVersion - 3).padEnd(maxVersion);
|
|
54
|
+
for await (const [valueType, value] of new ServiceRunner(v).action(action)) {
|
|
55
|
+
const details = { [valueType === 'message' ? 'subtitle' : valueType]: value };
|
|
56
|
+
q.add({ idx: i, text: cliTpl`${{ identifier }} ${{ type }} ${details}` });
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
Promise.all(jobs).then(() => Util.queueMacroTask()).then(() => q.close());
|
|
61
|
+
|
|
62
|
+
const term = new Terminal();
|
|
63
|
+
await term.writer.writeLines([
|
|
64
|
+
'',
|
|
65
|
+
cliTpl`${{ title: 'Service'.padEnd(maxName) }} ${{ title: 'Version'.padEnd(maxVersion) }} ${{ title: 'Status' }}`,
|
|
66
|
+
''.padEnd(maxName + maxVersion + maxStatus + 3, '-'),
|
|
67
|
+
]).commit();
|
|
68
|
+
|
|
69
|
+
await term.streamList(q);
|
|
70
|
+
}
|
|
71
|
+
}
|