neonctl 1.3.0 → 1.4.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.
@@ -1,20 +1,13 @@
1
- import yargs from 'yargs';
2
- import { hideBin } from 'yargs/helpers';
3
1
  import { projectCreateRequest } from '../parameters.gen.js';
4
- import { writeOut } from '../writer.js';
2
+ import { commandFailHandler } from '../utils.js';
3
+ import { writer } from '../writer.js';
5
4
  const PROJECT_FIELDS = ['id', 'name', 'region_id', 'created_at'];
6
5
  export const command = 'projects [command]';
7
6
  export const describe = 'Manage projects';
8
7
  export const builder = (argv) => {
9
8
  return argv
10
9
  .demandCommand(1, '')
11
- .fail((_msg, _err, yyargs) => {
12
- const y = yargs(hideBin(process.argv));
13
- if (y.argv._.length === 1) {
14
- yyargs.showHelp();
15
- process.exit(1);
16
- }
17
- })
10
+ .fail(commandFailHandler)
18
11
  .usage('usage: $0 projects <cmd> [args]')
19
12
  .command('list', 'List projects', (yargs) => yargs, async (args) => {
20
13
  await list(args);
@@ -39,15 +32,23 @@ export const builder = (argv) => {
39
32
  },
40
33
  }), async (args) => {
41
34
  await deleteProject(args);
35
+ })
36
+ .command('get', 'Get a project', (yargs) => yargs.options({
37
+ 'project.id': {
38
+ describe: 'Project ID',
39
+ type: 'string',
40
+ demandOption: true,
41
+ },
42
+ }), async (args) => {
43
+ await get(args);
42
44
  });
43
45
  };
44
46
  export const handler = (args) => {
45
47
  return args;
46
48
  };
47
49
  const list = async (props) => {
48
- writeOut(props)((await props.apiClient.listProjects({})).data.projects, {
49
- fields: PROJECT_FIELDS,
50
- });
50
+ const { data } = await props.apiClient.listProjects({});
51
+ writer(props).end(data.projects, { fields: PROJECT_FIELDS });
51
52
  };
52
53
  const create = async (props) => {
53
54
  if (props.project == null) {
@@ -60,17 +61,24 @@ const create = async (props) => {
60
61
  props.project = answers;
61
62
  }
62
63
  }
63
- writeOut(props)((await props.apiClient.createProject({
64
+ const { data } = await props.apiClient.createProject({
64
65
  project: props.project,
65
- })).data.project, { fields: PROJECT_FIELDS });
66
+ });
67
+ writer(props).end(data.project, { fields: PROJECT_FIELDS });
66
68
  };
67
69
  const deleteProject = async (props) => {
68
- writeOut(props)((await props.apiClient.deleteProject(props.project.id)).data.project, {
70
+ const { data } = await props.apiClient.deleteProject(props.project.id);
71
+ writer(props).end(data.project, {
69
72
  fields: PROJECT_FIELDS,
70
73
  });
71
74
  };
72
75
  const update = async (props) => {
73
- writeOut(props)((await props.apiClient.updateProject(props.project.id, {
76
+ const { data } = await props.apiClient.updateProject(props.project.id, {
74
77
  project: props.project,
75
- })).data.project, { fields: PROJECT_FIELDS });
78
+ });
79
+ writer(props).end(data.project, { fields: PROJECT_FIELDS });
80
+ };
81
+ const get = async (props) => {
82
+ const { data } = await props.apiClient.getProject(props.project.id);
83
+ writer(props).end(data.project, { fields: PROJECT_FIELDS });
76
84
  };
@@ -0,0 +1,46 @@
1
+ import { describe } from '@jest/globals';
2
+ import { testCliCommand } from '../test_utils.js';
3
+ describe('projects', () => {
4
+ testCliCommand({
5
+ name: 'list',
6
+ args: ['projects', 'list'],
7
+ expected: {
8
+ snapshot: true,
9
+ },
10
+ });
11
+ testCliCommand({
12
+ name: 'create',
13
+ args: ['projects', 'create', '--project.name', 'test_project'],
14
+ expected: {
15
+ snapshot: true,
16
+ },
17
+ });
18
+ testCliCommand({
19
+ name: 'delete',
20
+ args: ['projects', 'delete', '--project.id', 'test'],
21
+ expected: {
22
+ snapshot: true,
23
+ },
24
+ });
25
+ testCliCommand({
26
+ name: 'update',
27
+ args: [
28
+ 'projects',
29
+ 'update',
30
+ '--project.id',
31
+ 'test',
32
+ '--project.name',
33
+ 'test_project',
34
+ ],
35
+ expected: {
36
+ snapshot: true,
37
+ },
38
+ });
39
+ testCliCommand({
40
+ name: 'get',
41
+ args: ['projects', 'get', '--project.id', 'test'],
42
+ expected: {
43
+ snapshot: true,
44
+ },
45
+ });
46
+ });
package/commands/users.js CHANGED
@@ -1,4 +1,4 @@
1
- import { writeOut } from '../writer.js';
1
+ import { writer } from '../writer.js';
2
2
  export const command = 'me';
3
3
  export const describe = 'Show current user';
4
4
  export const builder = (yargs) => yargs;
@@ -6,7 +6,8 @@ export const handler = async (args) => {
6
6
  await me(args);
7
7
  };
8
8
  const me = async (props) => {
9
- writeOut(props)((await props.apiClient.getCurrentUserInfo()).data, {
9
+ const { data } = await props.apiClient.getCurrentUserInfo();
10
+ writer(props).end(data, {
10
11
  fields: ['login', 'email', 'name', 'projects_limit'],
11
12
  });
12
13
  };
package/config.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { join } from 'node:path';
2
2
  import { homedir } from 'node:os';
3
3
  import { existsSync, mkdirSync } from 'node:fs';
4
+ import { isCi } from './env.js';
4
5
  export const defaultDir = join(homedir(), process.env.XDG_CONFIG_HOME || '.config', 'neonctl');
5
6
  export const ensureConfigDir = async ({ 'config-dir': configDir, }) => {
6
- if (!existsSync(configDir)) {
7
+ if (!existsSync(configDir) && !isCi()) {
7
8
  mkdirSync(configDir);
8
9
  }
9
10
  };
package/env.js ADDED
@@ -0,0 +1,3 @@
1
+ export const isCi = () => {
2
+ return process.env.CI !== 'false' && Boolean(process.env.CI);
3
+ };
package/index.js CHANGED
@@ -2,7 +2,18 @@ import yargs from 'yargs';
2
2
  import { hideBin } from 'yargs/helpers';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { readFileSync } from 'node:fs';
5
- import 'axios-debug-log';
5
+ import axiosDebug from 'axios-debug-log';
6
+ axiosDebug({
7
+ request(debug, config) {
8
+ debug(`${config.method?.toUpperCase()} ${config.url}`);
9
+ },
10
+ response(debug, response) {
11
+ debug(`${response.status} ${response.statusText}`);
12
+ },
13
+ error(debug, error) {
14
+ debug(error);
15
+ },
16
+ });
6
17
  import { ensureAuth } from './commands/auth.js';
7
18
  import { defaultDir, ensureConfigDir } from './config.js';
8
19
  import { log } from './log.js';
package/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "url": "git@github.com:neondatabase/neonctl.git"
6
6
  },
7
7
  "type": "module",
8
- "version": "1.3.0",
8
+ "version": "1.4.0",
9
9
  "description": "CLI tool for NeonDB Cloud management",
10
10
  "main": "index.js",
11
11
  "author": "NeonDB",
@@ -19,19 +19,25 @@
19
19
  },
20
20
  "devDependencies": {
21
21
  "@apidevtools/swagger-parser": "^10.1.0",
22
+ "@jest/globals": "^29.5.0",
22
23
  "@semantic-release/exec": "^6.0.3",
23
24
  "@semantic-release/git": "^10.0.1",
24
25
  "@types/cli-table": "^0.3.0",
26
+ "@types/express": "^4.17.17",
25
27
  "@types/inquirer": "^9.0.3",
26
28
  "@types/node": "^18.7.13",
27
29
  "@types/yargs": "^17.0.24",
28
30
  "@typescript-eslint/eslint-plugin": "^5.34.0",
29
31
  "@typescript-eslint/parser": "^5.34.0",
32
+ "emocks": "^3.0.1",
30
33
  "eslint": "^8.22.0",
34
+ "express": "^4.18.2",
31
35
  "husky": "^8.0.1",
36
+ "jest": "^29.5.0",
32
37
  "lint-staged": "^13.0.3",
33
38
  "prettier": "^2.7.1",
34
39
  "semantic-release": "^21.0.2",
40
+ "ts-jest": "^29.1.0",
35
41
  "ts-node": "^10.9.1",
36
42
  "typescript": "^4.7.4"
37
43
  },
@@ -54,10 +60,11 @@
54
60
  "scripts": {
55
61
  "watch": "tsc --watch",
56
62
  "lint": "tsc --noEmit && eslint src --ext .ts",
57
- "build": "npm run generateParams && npm run clean && tsc && cp src/*.html dist/ && cp package.json ./dist",
63
+ "build": "npm run generateParams && npm run clean && tsc && cp src/*.html package.json README.md ./dist",
58
64
  "clean": "rm -rf dist",
59
65
  "generateParams": "ts-node --esm generateOptionsFromSpec.ts",
60
- "start": "node src/index.js"
66
+ "start": "node src/index.js",
67
+ "test": "node --experimental-vm-modules node_modules/.bin/jest"
61
68
  },
62
69
  "lint-staged": {
63
70
  "*.ts": [
package/parameters.gen.js CHANGED
@@ -3,98 +3,221 @@ export const projectCreateRequest = {
3
3
  'project.settings.quota.active_time_seconds': {
4
4
  type: "number",
5
5
  description: "The total amount of wall-clock time allowed to be spent by project's compute endpoints.\n",
6
+ demandOption: false,
6
7
  },
7
8
  'project.settings.quota.compute_time_seconds': {
8
9
  type: "number",
9
10
  description: "The total amount of CPU seconds allowed to be spent by project's compute endpoints.\n",
11
+ demandOption: false,
10
12
  },
11
13
  'project.settings.quota.written_data_bytes': {
12
14
  type: "number",
13
15
  description: "Total amount of data written to all project's branches.\n",
16
+ demandOption: false,
14
17
  },
15
18
  'project.settings.quota.data_transfer_bytes': {
16
19
  type: "number",
17
20
  description: "Total amount of data transferred from all project's branches using proxy.\n",
21
+ demandOption: false,
18
22
  },
19
23
  'project.settings.quota.logical_size_bytes': {
20
24
  type: "number",
21
25
  description: "Limit on the logical size of every project's branch.\n",
26
+ demandOption: false,
22
27
  },
23
28
  'project.name': {
24
29
  type: "string",
25
30
  description: "The project name",
31
+ demandOption: false,
26
32
  },
27
33
  'project.branch.name': {
28
34
  type: "string",
29
35
  description: "The branch name. If not specified, the default branch name will be used.\n",
36
+ demandOption: false,
30
37
  },
31
38
  'project.branch.role_name': {
32
39
  type: "string",
33
40
  description: "The role name. If not specified, the default role name will be used.\n",
41
+ demandOption: false,
34
42
  },
35
43
  'project.branch.database_name': {
36
44
  type: "string",
37
45
  description: "The database name. If not specified, the default database name will be used.\n",
46
+ demandOption: false,
38
47
  },
39
48
  'project.provisioner': {
40
49
  type: "string",
41
50
  description: "The Neon compute provisioner.\n",
51
+ demandOption: false,
42
52
  choices: ["k8s-pod", "k8s-neonvm", "docker"],
43
53
  },
44
54
  'project.region_id': {
45
55
  type: "string",
46
56
  description: "The region identifier. See [the documentation](https://neon.tech/docs/introduction/regions) for the list of supported regions.\n",
57
+ demandOption: false,
47
58
  },
48
59
  'project.pg_version': {
49
60
  type: "number",
50
61
  description: "The major PostgreSQL version number. Currently supported version are `14` and `15`.",
62
+ demandOption: false,
51
63
  },
52
64
  'project.store_passwords': {
53
65
  type: "boolean",
54
66
  description: "Whether or not passwords are stored for roles in the Neon project. Storing passwords facilitates access to Neon features that require authorization.\n",
67
+ demandOption: false,
55
68
  },
56
69
  'project.history_retention_seconds': {
57
70
  type: "number",
58
71
  description: "The number of seconds to retain PITR backup history for this project. Defaults to 7 days\n",
72
+ demandOption: false,
59
73
  },
60
74
  };
61
75
  export const branchCreateRequest = {
62
76
  'branch.parent_id': {
63
77
  type: "string",
64
78
  description: "The `branch_id` of the parent branch\n",
79
+ demandOption: false,
65
80
  },
66
81
  'branch.name': {
67
82
  type: "string",
68
83
  description: "The branch name\n",
84
+ demandOption: false,
69
85
  },
70
86
  'branch.parent_lsn': {
71
87
  type: "string",
72
88
  description: "A Log Sequence Number (LSN) on the parent branch. The branch will be created with data from this LSN.\n",
89
+ demandOption: false,
73
90
  },
74
91
  'branch.parent_timestamp': {
75
92
  type: "string",
76
93
  description: "A timestamp identifying a point in time on the parent branch. The branch will be created with data starting from this point in time.\n",
94
+ demandOption: false,
77
95
  },
78
96
  };
79
97
  export const branchCreateRequestEndpointOptions = {
80
98
  'type': {
81
99
  type: "string",
82
100
  description: "The compute endpoint type. Either `read_write` or `read_only`.\nThe `read_only` compute endpoint type is not yet supported.\n",
101
+ demandOption: true,
83
102
  choices: ["read_only", "read_write"],
84
103
  },
85
104
  'provisioner': {
86
105
  type: "string",
87
106
  description: "The Neon compute provisioner.\n",
107
+ demandOption: false,
88
108
  choices: ["k8s-pod", "k8s-neonvm", "docker"],
89
109
  },
90
110
  'suspend_timeout_seconds': {
91
111
  type: "number",
92
112
  description: "Duration of inactivity in seconds after which endpoint will be\nautomatically suspended. Value `0` means use global default,\n`-1` means never suspend. Maximum value is 1 week in seconds.\n",
113
+ demandOption: false,
93
114
  },
94
115
  };
95
116
  export const branchUpdateRequest = {
96
117
  'branch.name': {
97
118
  type: "string",
98
119
  description: undefined,
120
+ demandOption: false,
121
+ },
122
+ };
123
+ export const endpointCreateRequest = {
124
+ 'endpoint.branch_id': {
125
+ type: "string",
126
+ description: "The ID of the branch the compute endpoint will be associated with\n",
127
+ demandOption: true,
128
+ },
129
+ 'endpoint.region_id': {
130
+ type: "string",
131
+ description: "The region where the compute endpoint will be created. Only the project's `region_id` is permitted.\n",
132
+ demandOption: false,
133
+ },
134
+ 'endpoint.type': {
135
+ type: "string",
136
+ description: "The compute endpoint type. Either `read_write` or `read_only`.\nThe `read_only` compute endpoint type is not yet supported.\n",
137
+ demandOption: true,
138
+ choices: ["read_only", "read_write"],
139
+ },
140
+ 'endpoint.provisioner': {
141
+ type: "string",
142
+ description: "The Neon compute provisioner.\n",
143
+ demandOption: false,
144
+ choices: ["k8s-pod", "k8s-neonvm", "docker"],
145
+ },
146
+ 'endpoint.pooler_enabled': {
147
+ type: "boolean",
148
+ description: "Whether to enable connection pooling for the compute endpoint\n",
149
+ demandOption: false,
150
+ },
151
+ 'endpoint.pooler_mode': {
152
+ type: "string",
153
+ description: "The connection pooler mode. Neon supports PgBouncer in `transaction` mode only.\n",
154
+ demandOption: false,
155
+ choices: ["transaction"],
156
+ },
157
+ 'endpoint.disabled': {
158
+ type: "boolean",
159
+ description: "Whether to restrict connections to the compute endpoint\n",
160
+ demandOption: false,
161
+ },
162
+ 'endpoint.passwordless_access': {
163
+ type: "boolean",
164
+ description: "NOT YET IMPLEMENTED. Whether to permit passwordless access to the compute endpoint.\n",
165
+ demandOption: false,
166
+ },
167
+ 'endpoint.suspend_timeout_seconds': {
168
+ type: "number",
169
+ description: "Duration of inactivity in seconds after which endpoint will be\nautomatically suspended. Value `0` means use global default,\n`-1` means never suspend. Maximum value is 1 week in seconds.\n",
170
+ demandOption: false,
171
+ },
172
+ };
173
+ export const endpointUpdateRequest = {
174
+ 'endpoint.branch_id': {
175
+ type: "string",
176
+ description: "The destination branch ID. The destination branch must not have an exsiting read-write endpoint.\n",
177
+ demandOption: false,
178
+ },
179
+ 'endpoint.provisioner': {
180
+ type: "string",
181
+ description: "The Neon compute provisioner.\n",
182
+ demandOption: false,
183
+ choices: ["k8s-pod", "k8s-neonvm", "docker"],
184
+ },
185
+ 'endpoint.pooler_enabled': {
186
+ type: "boolean",
187
+ description: "Whether to enable connection pooling for the compute endpoint\n",
188
+ demandOption: false,
189
+ },
190
+ 'endpoint.pooler_mode': {
191
+ type: "string",
192
+ description: "The connection pooler mode. Neon supports PgBouncer in `transaction` mode only.\n",
193
+ demandOption: false,
194
+ choices: ["transaction"],
195
+ },
196
+ 'endpoint.disabled': {
197
+ type: "boolean",
198
+ description: "Whether to restrict connections to the compute endpoint\n",
199
+ demandOption: false,
200
+ },
201
+ 'endpoint.passwordless_access': {
202
+ type: "boolean",
203
+ description: "NOT YET IMPLEMENTED. Whether to permit passwordless access to the compute endpoint.\n",
204
+ demandOption: false,
205
+ },
206
+ 'endpoint.suspend_timeout_seconds': {
207
+ type: "number",
208
+ description: "Duration of inactivity in seconds after which endpoint will be\nautomatically suspended. Value `0` means use global default,\n`-1` means never suspend. Maximum value is 1 week in seconds.\n",
209
+ demandOption: false,
210
+ },
211
+ };
212
+ export const databaseCreateRequest = {
213
+ 'database.name': {
214
+ type: "string",
215
+ description: "The name of the datbase\n",
216
+ demandOption: true,
217
+ },
218
+ 'database.owner_name': {
219
+ type: "string",
220
+ description: "The name of the role that owns the database\n",
221
+ demandOption: true,
99
222
  },
100
223
  };
package/test_utils.js ADDED
@@ -0,0 +1,75 @@
1
+ /* eslint-disable no-console */
2
+ import { test, expect, describe, beforeAll, afterAll } from '@jest/globals';
3
+ import emocks from 'emocks';
4
+ import express from 'express';
5
+ import { fork } from 'node:child_process';
6
+ import { join } from 'node:path';
7
+ const runMockServer = async () => new Promise((resolve) => {
8
+ const app = express();
9
+ app.use(express.json());
10
+ app.use('/', emocks(join(process.cwd(), 'mocks')));
11
+ const server = app.listen(0);
12
+ server.on('listening', () => {
13
+ console.log(`Mock server listening at ${server.address().port}`);
14
+ });
15
+ resolve(server);
16
+ });
17
+ export const testCliCommand = ({ args, name, expected, }) => {
18
+ let server;
19
+ describe(name, () => {
20
+ beforeAll(async () => {
21
+ server = await runMockServer();
22
+ });
23
+ afterAll(async () => {
24
+ return new Promise((resolve) => {
25
+ server.close(() => {
26
+ resolve();
27
+ });
28
+ });
29
+ });
30
+ test('test', async () => {
31
+ let output = '';
32
+ let error = '';
33
+ const cp = fork(join(process.cwd(), './dist/index.js'), [
34
+ '--api-host',
35
+ `http://localhost:${server.address().port}`,
36
+ '--api-key',
37
+ 'test-key',
38
+ ...args,
39
+ ], {
40
+ stdio: 'pipe',
41
+ });
42
+ return new Promise((resolve, reject) => {
43
+ cp.stdout?.on('data', (data) => {
44
+ output += data.toString();
45
+ });
46
+ cp.stderr?.on('data', (data) => {
47
+ error += data.toString();
48
+ });
49
+ cp.on('error', (err) => {
50
+ throw err;
51
+ });
52
+ cp.on('close', (code) => {
53
+ if (error) {
54
+ console.log(error);
55
+ }
56
+ try {
57
+ expect(code).toBe(0);
58
+ if (code === 0 && expected) {
59
+ if ('snapshot' in expected) {
60
+ expect(output).toMatchSnapshot();
61
+ }
62
+ else if ('output' in expected) {
63
+ expect(output).toEqual(expected.output);
64
+ }
65
+ }
66
+ resolve();
67
+ }
68
+ catch (err) {
69
+ reject(err);
70
+ }
71
+ });
72
+ });
73
+ });
74
+ });
75
+ };
package/utils.js CHANGED
@@ -1,3 +1,5 @@
1
+ import yargs from 'yargs';
2
+ import { hideBin } from 'yargs/helpers';
1
3
  /**
2
4
  * This middleware is needed to fill in the args for nested objects,
3
5
  * so that required arguments would work
@@ -18,3 +20,10 @@ export const fillInArgs = (args, currentArgs = args, acc = []) => {
18
20
  }
19
21
  });
20
22
  };
23
+ export const commandFailHandler = async (_msg, _err, yyargs) => {
24
+ const argv = yargs(hideBin(process.argv));
25
+ if (argv.argv._.length === 1) {
26
+ yyargs.showHelp();
27
+ process.exit(1);
28
+ }
29
+ };
package/writer.js CHANGED
@@ -1,62 +1,71 @@
1
1
  import YAML from 'yaml';
2
2
  import Table from 'cli-table';
3
3
  import chalk from 'chalk';
4
- // Allow PIPE to finish reading before the end of the output.
5
- process.stdout.on('error', function (err) {
6
- if (err.code == 'EPIPE') {
7
- process.exit(0);
8
- }
9
- });
4
+ import { isCi } from './env.js';
10
5
  /**
11
6
  *
12
7
  * Parses the output format, takes data and writes the output to stdout.
13
8
  *
14
9
  * @example
15
- * // to output single data
16
10
  * const { data } = await props.apiClient.listProjectBranches(props.project.id);
17
- * writeOut(props)(data, { fields: ['id', 'name', 'created_at'] })
11
+ * // to output single data
12
+ * writer(props).end(data, { fields: ['id', 'name', 'created_at'] })
18
13
  * // to output multiple data
19
- * writeOut(props)({
20
- * branches: {
21
- * data,
22
- * config: { fields: ['id', 'name', 'created_at'] }
23
- * },
24
- * endpoints: {
25
- * data,
26
- * config: { fields: ['id', 'created_at'] }
27
- * }
28
- * })
14
+ * writer(props)
15
+ * .write(data, { fields: ['id', 'name', 'created_at'], title: 'branches' })
16
+ * .write(data, { fields: ['id', 'created_at'], title: 'endpoints' })
17
+ * .end()
29
18
  */
30
- export const writeOut = (props) => (arg1, arg2) => {
31
- if (props.output == 'yaml') {
32
- process.stdout.write(YAML.stringify(arg2 === undefined
33
- ? Object.fromEntries(Object.entries(arg1).map(([k, v]) => [k, v.data]))
34
- : arg1, null, 2));
35
- return;
36
- }
37
- if (props.output == 'json') {
38
- process.stdout.write(JSON.stringify(arg2 === undefined
39
- ? Object.fromEntries(Object.entries(arg1).map(([k, v]) => [k, v.data]))
40
- : arg1, null, 2));
41
- return;
42
- }
43
- const options = arg2 === undefined ? arg1 : { '': { data: arg1, config: arg2 } };
44
- Object.entries(options).forEach(([key, { data, config }]) => {
45
- const arrayData = Array.isArray(data) ? data : [data];
46
- const table = new Table({
47
- style: {
48
- head: ['green'],
49
- },
50
- head: config.fields.map((field) => field
51
- .split('_')
52
- .map((word) => word[0].toUpperCase() + word.slice(1))
53
- .join(' ')),
54
- });
55
- arrayData.forEach((item) => {
56
- table.push(config.fields.map((field) => item[field] ?? ''));
57
- });
58
- process.stdout.write(chalk.bold(key) + '\n');
59
- process.stdout.write(table.toString());
60
- process.stdout.write('\n');
61
- });
19
+ export const writer = (props) => {
20
+ const out = props.out ?? process.stdout;
21
+ const chunks = [];
22
+ return {
23
+ write(data, config) {
24
+ chunks.push({ data, config });
25
+ return this;
26
+ },
27
+ end: (...args) => {
28
+ if (args.length === 2) {
29
+ chunks.push({ data: args[0], config: args[1] });
30
+ }
31
+ if (props.output == 'yaml') {
32
+ out.write(YAML.stringify(chunks.length === 1
33
+ ? chunks[0].data
34
+ : Object.fromEntries(chunks.map(({ config, data }, idx) => [
35
+ config.title ?? idx,
36
+ data,
37
+ ])), null, 2));
38
+ return;
39
+ }
40
+ if (props.output == 'json') {
41
+ out.write(JSON.stringify(chunks.length === 1
42
+ ? chunks[0].data
43
+ : Object.fromEntries(chunks.map(({ config, data }, idx) => [
44
+ config.title ?? idx,
45
+ data,
46
+ ])), null, 2));
47
+ return;
48
+ }
49
+ chunks.forEach(({ data, config }) => {
50
+ const arrayData = Array.isArray(data) ? data : [data];
51
+ const table = new Table({
52
+ style: {
53
+ head: ['green'],
54
+ },
55
+ head: config.fields.map((field) => field
56
+ .split('_')
57
+ .map((word) => word[0].toUpperCase() + word.slice(1))
58
+ .join(' ')),
59
+ });
60
+ arrayData.forEach((item) => {
61
+ table.push(config.fields.map((field) => item[field] ?? ''));
62
+ });
63
+ if (config.title) {
64
+ out.write((isCi() ? config.title : chalk.bold(config.title)) + '\n');
65
+ }
66
+ out.write(table.toString());
67
+ out.write('\n');
68
+ });
69
+ },
70
+ };
62
71
  };