@cloud-cli/vm 1.3.1 → 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,13 +1,10 @@
1
1
  name: CICD
2
2
  on:
3
- push:
4
- branches:
5
- - main
3
+ - push
4
+
6
5
  jobs:
7
6
  release:
8
- uses: homebots/workflows/.github/workflows/node-build-release.yml@master
9
- with:
10
- branches: main
7
+ uses: cloud-cli/workflows/.github/workflows/npm-build-release.yml@main
11
8
  secrets:
12
9
  NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
13
10
  GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
package/dist/index.d.ts CHANGED
@@ -1,7 +1,11 @@
1
- interface NameOption {
1
+ import { help } from "@cloud-cli/cli";
2
+ interface Options {
3
+ _?: string[];
4
+ }
5
+ interface NameOption extends Options {
2
6
  name: string;
3
7
  }
4
- interface NameAndPathOption {
8
+ interface NameAndPathOption extends Options {
5
9
  name: string;
6
10
  path?: string;
7
11
  }
@@ -28,5 +32,6 @@ declare const _default: {
28
32
  rm: typeof rm;
29
33
  cat: typeof cat;
30
34
  fixPermissions: typeof fixPermissions;
35
+ [help]: () => string;
31
36
  };
32
37
  export default _default;
package/dist/index.js CHANGED
@@ -1,61 +1,70 @@
1
- import { join } from 'node:path';
2
- import { readFile } from 'node:fs/promises';
3
- import { exec } from '@cloud-cli/exec';
1
+ import { join } from "node:path";
2
+ import { readFile } from "node:fs/promises";
3
+ import { exec } from "@cloud-cli/exec";
4
+ import { help } from "@cloud-cli/cli";
4
5
  const nameRe = /^[a-z][a-z0-9-]+$/;
5
6
  const isValidName = (name) => nameRe.test(name);
6
- const invalidNameError = new Error('Invalid name');
7
+ const invalidNameError = new Error("Invalid name");
8
+ const readOptions = (options) => {
9
+ options.name || (options.name = options._?.[0]);
10
+ if (!isValidName(options.name)) {
11
+ throw invalidNameError;
12
+ }
13
+ };
7
14
  async function list() {
8
- const output = await exec('docker', ['volume', 'ls', '--format={{.Name}}']);
15
+ const output = await exec("docker", ["volume", "ls", "--format={{.Name}}"]);
9
16
  if (!output.ok) {
10
- throw new Error('Unable to list volumes');
17
+ throw new Error("Unable to list volumes");
11
18
  }
12
- return output.stdout.trim().split('\n');
19
+ return output.stdout.trim().split("\n");
13
20
  }
14
21
  async function getVolumeMountpoint(name) {
15
- const output = await exec('docker', ['volume', 'inspect', name]);
22
+ const output = await exec("docker", ["volume", "inspect", name]);
16
23
  const volumes = JSON.parse(output.stdout);
17
24
  if (!volumes.length) {
18
- throw new Error('Volume not found');
25
+ throw new Error("Volume not found");
19
26
  }
20
27
  return volumes[0].Mountpoint;
21
28
  }
22
29
  async function ls(options) {
23
- const { name, path = '' } = options;
30
+ readOptions(options);
31
+ const { name, path = "" } = options;
24
32
  const root = await getVolumeMountpoint(name);
25
- const files = await exec('ls', ['-1', join(root, path)]);
26
- return files.stdout.trim().split('\n').filter(Boolean);
33
+ const files = await exec("ls", ["-1", join(root, path)]);
34
+ return files.stdout.trim().split("\n").filter(Boolean);
27
35
  }
28
36
  async function fixPermissions(options) {
37
+ readOptions(options);
29
38
  const { name } = options;
30
39
  const root = await getVolumeMountpoint(name);
31
- const result = await exec('chmod', ['-R', 'a+w', root]);
40
+ const result = await exec("chmod", ["-R", "a+w", root]);
32
41
  return result.ok;
33
42
  }
34
43
  async function rm(options) {
44
+ readOptions(options);
35
45
  const { name, path } = options;
36
46
  if (!path) {
37
- throw new Error('Path not specified');
47
+ throw new Error("Path not specified");
38
48
  }
39
49
  const root = await getVolumeMountpoint(name);
40
- const result = await exec('rm', ['-r', join(root, path)]);
50
+ const result = await exec("rm", ["-r", join(root, path)]);
41
51
  return result.ok;
42
52
  }
43
53
  async function cat(options) {
54
+ readOptions(options);
44
55
  const { name, path } = options;
45
56
  if (!path) {
46
- throw new Error('Path not specified');
57
+ throw new Error("Path not specified");
47
58
  }
48
59
  const root = await getVolumeMountpoint(name);
49
- return await readFile(join(root, path), 'utf8');
60
+ return await readFile(join(root, path), "utf8");
50
61
  }
51
62
  async function show(options) {
52
- if (!isValidName(options.name)) {
53
- throw invalidNameError;
54
- }
55
- const output = await exec('docker', ['volume', 'inspect', options.name]);
63
+ readOptions(options);
64
+ const output = await exec("docker", ["volume", "inspect", options.name]);
56
65
  const list = JSON.parse(output.stdout);
57
66
  if (!list.length) {
58
- throw new Error('Volume not found');
67
+ throw new Error("Volume not found");
59
68
  }
60
69
  const { Name, Labels, CreatedAt } = list[0];
61
70
  return {
@@ -65,27 +74,50 @@ async function show(options) {
65
74
  };
66
75
  }
67
76
  async function add(options) {
68
- if (!isValidName(options.name)) {
69
- throw invalidNameError;
70
- }
71
- const output = await exec('docker', ['volume', 'create', options.name]);
77
+ readOptions(options);
78
+ const output = await exec("docker", ["volume", "create", options.name]);
72
79
  if (output.ok) {
80
+ await fixPermissions(options);
73
81
  return true;
74
82
  }
75
- throw new Error('Unable to create volume');
83
+ throw new Error("Unable to create volume");
76
84
  }
77
85
  async function remove(options) {
78
- if (!isValidName(options.name)) {
79
- throw invalidNameError;
80
- }
81
- const output = await exec('docker', ['volume', 'rm', options.name]);
86
+ readOptions(options);
87
+ const output = await exec("docker", ["volume", "rm", options.name]);
82
88
  if (output.ok) {
83
89
  return true;
84
90
  }
85
- throw new Error('Unable to remove volume');
91
+ throw new Error("Unable to remove volume");
86
92
  }
87
93
  async function prune() {
88
- await exec('docker', ['volume', 'prune']);
89
- return '';
94
+ await exec("docker", ["volume", "prune"]);
95
+ return "";
90
96
  }
91
- export default { add, remove, list, show, prune, ls, rm, cat, fixPermissions };
97
+ export default {
98
+ add,
99
+ remove,
100
+ list,
101
+ show,
102
+ prune,
103
+ ls,
104
+ rm,
105
+ cat,
106
+ fixPermissions,
107
+ [help]: () => `Manage Docker volumes
108
+
109
+ Available commands:
110
+ vm.add --name <name> - Create a new Docker volume (name must be lowercase alphanumeric with hyphens)
111
+ vm.remove --name <name> - Remove a Docker volume
112
+ vm.list - List all Docker volumes
113
+ vm.ls --name <name> [--path <path>] - List files in a volume
114
+ vm.cat --name <name> --path <path> - Read a file from a volume
115
+ vm.show --name <name> - Show volume details
116
+ vm.fixPermissions --name <name> - Fix permissions on a volume
117
+ vm.rm --name <name> --path <path> - Remove a file or directory from a volume
118
+ vm.prune - Remove all unused Docker volumes
119
+
120
+ Options:
121
+ name - Volume name (lowercase alphanumeric with hyphens)
122
+ path - Path within the volume`,
123
+ };
package/package.json CHANGED
@@ -1,14 +1,13 @@
1
1
  {
2
2
  "name": "@cloud-cli/vm",
3
- "version": "1.3.1",
3
+ "version": "1.4.0",
4
4
  "description": "Docker volume management",
5
- "main": "dist/index.js",
6
- "types": "dist/index.d.ts",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
7
  "type": "module",
8
8
  "prettier": "@cloud-cli/prettier-config",
9
9
  "scripts": {
10
- "test": "jest",
11
- "tdd": "jest --watchAll",
10
+ "test": "vitest",
12
11
  "build": "tsc"
13
12
  },
14
13
  "keywords": [
@@ -24,11 +23,16 @@
24
23
  "author": "Darlan Alves <me@darlanalv.es>",
25
24
  "license": "MIT",
26
25
  "devDependencies": {
27
- "@cloud-cli/jest-config": "^1.0.0",
28
26
  "@cloud-cli/prettier-config": "^1.0.0",
29
- "@cloud-cli/typescript-config": "^1.0.1"
27
+ "@cloud-cli/typescript-config": "^1.0.1",
28
+ "@types/node": "^20.19.43",
29
+ "typescript": "^5.4.5",
30
+ "vitest": "^1.6.0"
30
31
  },
31
32
  "dependencies": {
32
33
  "@cloud-cli/exec": "^1.0.1"
34
+ },
35
+ "peerDependencies": {
36
+ "@cloud-cli/cli": "^2.2.0"
33
37
  }
34
38
  }
package/src/index.spec.ts CHANGED
@@ -1,17 +1,22 @@
1
- const execMocks = {
2
- exec: jest.fn(),
3
- };
4
-
5
- const fsMocks = {
6
- readFile: jest.fn(),
7
- };
8
-
9
- jest.mock('@cloud-cli/exec', () => execMocks);
10
- jest.mock('node:fs/promises', () => fsMocks);
11
-
12
- import vm from './index';
13
- import * as exec from '@cloud-cli/exec';
14
- import * as fs from 'node:fs/promises';
1
+ import { describe, expect, it, vi, beforeEach } from "vitest";
2
+ import { help } from "@cloud-cli/cli";
3
+ import vm from "./index";
4
+
5
+ const execMocks = vi.hoisted(() => ({
6
+ exec: vi.fn(),
7
+ readFile: vi.fn(),
8
+ }));
9
+
10
+ vi.mock("get-port", () => ({ default: vi.fn().mockReturnValue(1234) }));
11
+ vi.mock("@cloud-cli/exec", () => ({ exec: execMocks.exec }));
12
+ vi.mock("node:fs/promises", () => ({ readFile: execMocks.readFile }));
13
+ vi.mock("@cloud-cli/cli", async (importOriginal) => {
14
+ const mod: any = await importOriginal();
15
+ return {
16
+ ...mod,
17
+ help: mod.help,
18
+ };
19
+ });
15
20
 
16
21
  const inspectOutput = `[{
17
22
  "CreatedAt": "2023-03-10T10:15:10Z",
@@ -26,191 +31,217 @@ const inspectOutput = `[{
26
31
  "Scope": "local"
27
32
  }]`;
28
33
 
29
- describe('volume manager', () => {
30
- describe('list', () => {
31
- it('should list the available volumes', async () => {
34
+ describe("help", () => {
35
+ it("should have a [help] Symbol export that is a function", () => {
36
+ expect(vm[help]).toBeDefined();
37
+ expect(typeof vm[help]).toBe("function");
38
+ });
39
+
40
+ it("should return a string help text", () => {
41
+ const helpText = vm[help]();
42
+ expect(typeof helpText).toBe("string");
43
+ expect(helpText).toContain("Docker");
44
+ expect(helpText).toContain("vm.add");
45
+ expect(helpText).toContain("vm.remove");
46
+ expect(helpText).toContain("vm.list");
47
+ expect(helpText).toContain("vm.ls");
48
+ expect(helpText).toContain("vm.cat");
49
+ expect(helpText).toContain("vm.show");
50
+ expect(helpText).toContain("vm.fixPermissions");
51
+ expect(helpText).toContain("vm.rm");
52
+ expect(helpText).toContain("vm.prune");
53
+ });
54
+
55
+ it('should not expose "help" as a normal command key', () => {
56
+ expect(Object.hasOwn(vm, "help")).toBe(false);
57
+ });
58
+ });
59
+
60
+ describe("volume manager", () => {
61
+ beforeEach(() => {
62
+ execMocks.exec.mockReset();
63
+ execMocks.readFile.mockReset();
64
+ });
65
+
66
+ describe("list", () => {
67
+ it("should list the available volumes", async () => {
32
68
  const execOutput = {
33
69
  ok: true,
34
70
  stdout: `test\ntest2\n`,
35
71
  };
36
72
 
37
- execMocks.exec = jest.fn().mockResolvedValue(execOutput);
73
+ execMocks.exec.mockResolvedValue(execOutput);
38
74
 
39
- await expect(vm.list()).resolves.toEqual(['test', 'test2']);
40
- expect(exec.exec).toHaveBeenCalledWith('docker', ['volume', 'ls', '--format={{.Name}}']);
75
+ await expect(vm.list()).resolves.toEqual(["test", "test2"]);
76
+ expect(execMocks.exec).toHaveBeenCalledWith("docker", ["volume", "ls", "--format={{.Name}}"]);
41
77
  });
42
78
 
43
- it('should reject in case of error', async () => {
79
+ it("should reject in case of error", async () => {
44
80
  const output = { ok: false };
45
- execMocks.exec = jest.fn().mockResolvedValue(output);
81
+ execMocks.exec.mockResolvedValue(output);
46
82
 
47
- await expect(vm.list()).rejects.toThrowError('Unable to list volumes');
83
+ await expect(vm.list()).rejects.toThrowError("Unable to list volumes");
48
84
  });
49
85
  });
50
86
 
51
- describe('fixPermissions', () => {
52
- it('should fix write permissions on volume folder', async () => {
53
- const outputs = [inspectOutput, ''];
54
- execMocks.exec = jest.fn().mockImplementation(() => ({ ok: true, stdout: outputs.shift() }));
87
+ describe("fixPermissions", () => {
88
+ it("should fix write permissions on volume folder", async () => {
89
+ const outputs = [inspectOutput, ""];
90
+ execMocks.exec.mockImplementation(() => ({ ok: true, stdout: outputs.shift() }));
55
91
 
56
- await expect(vm.fixPermissions({ name: 'test' })).resolves.toEqual(true);
57
- expect(exec.exec).toHaveBeenCalledWith('docker', ['volume', 'inspect', 'test']);
58
- expect(exec.exec).toHaveBeenCalledWith('chmod', ['-R', 'a+w', '/var/lib/docker/volumes/test/_data']);
92
+ await expect(vm.fixPermissions({ name: "test" })).resolves.toEqual(true);
93
+ expect(execMocks.exec).toHaveBeenCalledWith("docker", ["volume", "inspect", "test"]);
94
+ expect(execMocks.exec).toHaveBeenCalledWith("chmod", ["-R", "a+w", "/var/lib/docker/volumes/test/_data"]);
59
95
  });
60
96
  });
61
97
 
62
- describe('ls', () => {
63
- it('should list files of a volume and path', async () => {
64
- const outputs = [inspectOutput, 'dir', inspectOutput, 'a.txt\nb.txt'];
65
- execMocks.exec = jest.fn().mockImplementation(() => ({ ok: true, stdout: outputs.shift() }));
98
+ describe("ls", () => {
99
+ it("should list files of a volume and path", async () => {
100
+ const outputs = [inspectOutput, "dir", inspectOutput, "a.txt\nb.txt"];
101
+ execMocks.exec.mockImplementation(() => ({ ok: true, stdout: outputs.shift() }));
66
102
 
67
- await expect(vm.ls({ name: 'test' })).resolves.toEqual(['dir']);
68
- await expect(vm.ls({ name: 'test', path: 'dir' })).resolves.toEqual(['a.txt', 'b.txt']);
103
+ await expect(vm.ls({ name: "test" })).resolves.toEqual(["dir"]);
104
+ await expect(vm.ls({ name: "test", path: "dir" })).resolves.toEqual(["a.txt", "b.txt"]);
69
105
 
70
- expect(exec.exec).toHaveBeenCalledWith('docker', ['volume', 'inspect', 'test']);
71
- expect(exec.exec).toHaveBeenCalledWith('ls', ['-1', '/var/lib/docker/volumes/test/_data']);
72
- expect(exec.exec).toHaveBeenCalledWith('ls', ['-1', '/var/lib/docker/volumes/test/_data/dir']);
106
+ expect(execMocks.exec).toHaveBeenCalledWith("docker", ["volume", "inspect", "test"]);
107
+ expect(execMocks.exec).toHaveBeenCalledWith("ls", ["-1", "/var/lib/docker/volumes/test/_data"]);
108
+ expect(execMocks.exec).toHaveBeenCalledWith("ls", ["-1", "/var/lib/docker/volumes/test/_data/dir"]);
73
109
  });
74
110
 
75
- it('should throw an error if volume name is invalid', async () => {
76
- execMocks.exec = jest.fn().mockImplementation(() => ({ ok: true, stdout: '[]' }));
77
- await expect(vm.ls({ name: 'notfound' })).rejects.toThrowError('Volume not found');
111
+ it("should throw an error if volume name is invalid", async () => {
112
+ execMocks.exec.mockImplementation(() => ({ ok: true, stdout: "[]" }));
113
+ await expect(vm.ls({ name: "notfound" })).rejects.toThrowError("Volume not found");
78
114
  });
79
115
  });
80
116
 
81
- describe('rm', () => {
82
- it('should remove files of a volume by path', async () => {
83
- const outputs = [inspectOutput, ''];
84
- execMocks.exec = jest.fn().mockImplementation(() => ({ ok: true, stdout: outputs.shift() }));
117
+ describe("rm", () => {
118
+ it("should remove files of a volume by path", async () => {
119
+ const outputs = [inspectOutput, ""];
120
+ execMocks.exec.mockImplementation(() => ({ ok: true, stdout: outputs.shift() }));
85
121
 
86
- await expect(vm.rm({ name: 'test', path: 'file.txt' })).resolves.toEqual(true);
122
+ await expect(vm.rm({ name: "test", path: "file.txt" })).resolves.toEqual(true);
87
123
 
88
- expect(exec.exec).toHaveBeenCalledWith('docker', ['volume', 'inspect', 'test']);
89
- expect(exec.exec).toHaveBeenCalledWith('rm', ['-r', '/var/lib/docker/volumes/test/_data/file.txt']);
124
+ expect(execMocks.exec).toHaveBeenCalledWith("docker", ["volume", "inspect", "test"]);
125
+ expect(execMocks.exec).toHaveBeenCalledWith("rm", ["-r", "/var/lib/docker/volumes/test/_data/file.txt"]);
90
126
  });
91
127
 
92
- it('should throw an error if volume name is invalid', async () => {
93
- execMocks.exec = jest.fn().mockImplementation(() => ({ ok: true, stdout: '[]' }));
94
- await expect(vm.rm({ name: 'notfound', path: 'file.txt' })).rejects.toThrowError('Volume not found');
128
+ it("should throw an error if volume name is invalid", async () => {
129
+ execMocks.exec.mockImplementation(() => ({ ok: true, stdout: "[]" }));
130
+ await expect(vm.rm({ name: "notfound", path: "file.txt" })).rejects.toThrowError("Volume not found");
95
131
  });
96
132
 
97
- it('should throw an error if path is invalid', async () => {
98
- execMocks.exec = jest.fn().mockImplementation(() => ({ ok: true, stdout: inspectOutput }));
99
- await expect(vm.rm({ name: 'nopath' })).rejects.toThrowError('Path not specified');
133
+ it("should throw an error if path is invalid", async () => {
134
+ execMocks.exec.mockImplementation(() => ({ ok: true, stdout: inspectOutput }));
135
+ await expect(vm.rm({ name: "nopath" })).rejects.toThrowError("Path not specified");
100
136
  expect(execMocks.exec).not.toHaveBeenCalled();
101
137
  });
102
138
  });
103
139
 
104
- describe('cat', () => {
105
- it('should read the content of a file in a volume by path', async () => {
106
- execMocks.exec = jest.fn().mockImplementation(() => ({ ok: true, stdout: inspectOutput }));
107
- fsMocks.readFile = jest.fn().mockImplementation(() => 'test file');
140
+ describe("cat", () => {
141
+ it("should read the content of a file in a volume by path", async () => {
142
+ execMocks.exec.mockImplementation(() => ({ ok: true, stdout: inspectOutput }));
143
+ execMocks.readFile.mockImplementation(() => "test file");
108
144
 
109
- await expect(vm.cat({ name: 'test', path: 'file.txt' })).resolves.toEqual('test file');
145
+ await expect(vm.cat({ name: "test", path: "file.txt" })).resolves.toEqual("test file");
110
146
 
111
- expect(exec.exec).toHaveBeenCalledWith('docker', ['volume', 'inspect', 'test']);
112
- expect(fs.readFile).toHaveBeenCalledWith('/var/lib/docker/volumes/test/_data/file.txt', 'utf8');
147
+ expect(execMocks.exec).toHaveBeenCalledWith("docker", ["volume", "inspect", "test"]);
148
+ expect(execMocks.readFile).toHaveBeenCalledWith("/var/lib/docker/volumes/test/_data/file.txt", "utf8");
113
149
  });
114
150
 
115
- it('should throw an error if volume name is invalid', async () => {
116
- execMocks.exec = jest.fn().mockImplementation(() => ({ ok: true, stdout: '[]' }));
117
- await expect(vm.cat({ name: 'notfound', path: 'file.txt' })).rejects.toThrowError('Volume not found');
151
+ it("should throw an error if volume name is invalid", async () => {
152
+ execMocks.exec.mockImplementation(() => ({ ok: true, stdout: "[]" }));
153
+ await expect(vm.cat({ name: "notfound", path: "file.txt" })).rejects.toThrowError("Volume not found");
118
154
  });
119
155
 
120
- it('should throw an error if path is invalid', async () => {
121
- execMocks.exec = jest.fn().mockImplementation(() => ({ ok: true, stdout: inspectOutput }));
122
- await expect(vm.cat({ name: 'nopath' })).rejects.toThrowError('Path not specified');
156
+ it("should throw an error if path is invalid", async () => {
157
+ execMocks.exec.mockImplementation(() => ({ ok: true, stdout: inspectOutput }));
158
+ await expect(vm.cat({ name: "nopath" })).rejects.toThrowError("Path not specified");
123
159
  expect(execMocks.exec).not.toHaveBeenCalled();
124
160
  });
125
161
  });
126
162
 
127
- describe('show', () => {
128
- it('should show details of a volume', async () => {
163
+ describe("show", () => {
164
+ it("should show details of a volume", async () => {
129
165
  const execOutput = { ok: true, stdout: inspectOutput };
130
- execMocks.exec = jest.fn().mockResolvedValue(execOutput);
166
+ execMocks.exec.mockResolvedValue(execOutput);
131
167
 
132
- await expect(vm.show({ name: 'test' })).resolves.toEqual({
133
- name: 'test',
134
- createdAt: '2023-03-10T10:15:10Z',
168
+ await expect(vm.show({ name: "test" })).resolves.toEqual({
169
+ name: "test",
170
+ createdAt: "2023-03-10T10:15:10Z",
135
171
  labels: {
136
- origin: 'test/container',
137
- version: '534524',
172
+ origin: "test/container",
173
+ version: "534524",
138
174
  },
139
175
  });
140
176
 
141
- expect(exec.exec).toHaveBeenCalledWith('docker', ['volume', 'inspect', 'test']);
177
+ expect(execMocks.exec).toHaveBeenCalledWith("docker", ["volume", "inspect", "test"]);
142
178
  });
143
179
 
144
- it('should throw an error if volume name is invalid', async () => {
145
- execMocks.exec = jest.fn();
146
- await expect(vm.show({ name: 'In$%valid' })).rejects.toThrowError('Invalid name');
147
- expect(exec.exec).not.toHaveBeenCalled();
180
+ it("should throw an error if volume name is invalid", async () => {
181
+ await expect(vm.show({ name: "In$%valid" })).rejects.toThrowError("Invalid name");
182
+ expect(execMocks.exec).not.toHaveBeenCalled();
148
183
  });
149
184
 
150
- it('should throw error if volume does not exist', async () => {
151
- const output = { ok: true, stdout: '[]' };
152
- execMocks.exec = jest.fn().mockResolvedValue(output);
185
+ it("should throw error if volume does not exist", async () => {
186
+ const output = { ok: true, stdout: "[]" };
187
+ execMocks.exec.mockResolvedValue(output);
153
188
 
154
- await expect(vm.show({ name: 'invalid' })).rejects.toThrowError('Volume not found');
189
+ await expect(vm.show({ name: "invalid" })).rejects.toThrowError("Volume not found");
155
190
  });
156
191
  });
157
192
 
158
- describe('add', () => {
159
- it('should add a volume', async () => {
160
- const execOutput = { ok: true, stdout: 'test' };
161
- execMocks.exec = jest.fn().mockResolvedValue(execOutput);
193
+ describe("add", () => {
194
+ it("should add a volume", async () => {
195
+ execMocks.exec
196
+ .mockResolvedValueOnce({ ok: true, stdout: "test" })
197
+ .mockResolvedValueOnce({ ok: true, stdout: inspectOutput })
198
+ .mockResolvedValueOnce({ ok: true, stdout: "" });
162
199
 
163
- await expect(vm.add({ name: 'test' })).resolves.toEqual(true);
200
+ await expect(vm.add({ name: "test" })).resolves.toEqual(true);
164
201
 
165
- expect(exec.exec).toHaveBeenCalledWith('docker', ['volume', 'create', 'test']);
202
+ expect(execMocks.exec).toHaveBeenCalledWith("docker", ["volume", "create", "test"]);
166
203
  });
167
204
 
168
- it('should throw error if volume name is invalid', async () => {
169
- execMocks.exec = jest.fn();
170
- await expect(vm.add({ name: 'In$%valid' })).rejects.toThrowError('Invalid name');
171
- expect(exec.exec).not.toHaveBeenCalled();
205
+ it("should throw error if volume name is invalid", async () => {
206
+ await expect(vm.add({ name: "In$%valid" })).rejects.toThrowError("Invalid name");
207
+ expect(execMocks.exec).not.toHaveBeenCalled();
172
208
  });
173
209
 
174
- it('should throw error if volume creation fails', async () => {
175
- const output = { ok: false, stdout: '' };
176
- execMocks.exec = jest.fn().mockResolvedValue(output);
210
+ it("should throw error if volume creation fails", async () => {
211
+ const output = { ok: false, stdout: "" };
212
+ execMocks.exec.mockResolvedValue(output);
177
213
 
178
- await expect(vm.add({ name: 'failed' })).rejects.toThrowError('Unable to create volume');
214
+ await expect(vm.add({ name: "failed" })).rejects.toThrowError("Unable to create volume");
179
215
  });
180
216
  });
181
217
 
182
- describe('remove', () => {
183
- it('should add a volume', async () => {
184
- const execOutput = { ok: true, stdout: '' };
185
- execMocks.exec = jest.fn().mockResolvedValue(execOutput);
186
-
187
- await expect(vm.remove({ name: 'test' })).resolves.toEqual(true);
218
+ describe("remove", () => {
219
+ it("should add a volume", async () => {
220
+ const execOutput = { ok: true, stdout: "" };
221
+ execMocks.exec.mockResolvedValue(execOutput);
188
222
 
189
- expect(exec.exec).toHaveBeenCalledWith('docker', ['volume', 'rm', 'test']);
223
+ await expect(vm.remove({ name: "test" })).resolves.toEqual(true);
190
224
  });
191
225
 
192
- it('should throw error if volume name is invalid', async () => {
193
- execMocks.exec = jest.fn();
194
- await expect(vm.remove({ name: 'In$%valid' })).rejects.toThrowError('Invalid name');
195
- expect(exec.exec).not.toHaveBeenCalled();
226
+ it("should throw error if volume name is invalid", async () => {
227
+ await expect(vm.remove({ name: "In$%valid" })).rejects.toThrowError("Invalid name");
228
+ expect(execMocks.exec).not.toHaveBeenCalled();
196
229
  });
197
230
 
198
- it('should throw error if volume removal fails', async () => {
199
- const output = { ok: false, stdout: '' };
200
- execMocks.exec = jest.fn().mockResolvedValue(output);
231
+ it("should throw error if volume removal fails", async () => {
232
+ const output = { ok: false, stdout: "" };
233
+ execMocks.exec.mockResolvedValue(output);
201
234
 
202
- await expect(vm.remove({ name: 'failed' })).rejects.toThrowError('Unable to remove volume');
235
+ await expect(vm.remove({ name: "failed" })).rejects.toThrowError("Unable to remove volume");
203
236
  });
204
237
  });
205
238
 
206
- describe('prune', () => {
207
- it('should remove ununsed volumes', async () => {
208
- const execOutput = { ok: true, stdout: '' };
209
- execMocks.exec = jest.fn().mockResolvedValue(execOutput);
210
-
211
- await expect(vm.prune()).resolves.toEqual('');
239
+ describe("prune", () => {
240
+ it("should remove ununsed volumes", async () => {
241
+ const execOutput = { ok: true, stdout: "" };
242
+ execMocks.exec.mockResolvedValue(execOutput);
212
243
 
213
- expect(exec.exec).toHaveBeenCalledWith('docker', ['volume', 'prune']);
244
+ await expect(vm.prune()).resolves.toEqual("");
214
245
  });
215
246
  });
216
247
  });
package/src/index.ts CHANGED
@@ -1,91 +1,104 @@
1
- import { join } from 'node:path';
2
- import { readFile } from 'node:fs/promises';
3
- import { exec } from '@cloud-cli/exec';
1
+ import { join } from "node:path";
2
+ import { readFile } from "node:fs/promises";
3
+ import { exec } from "@cloud-cli/exec";
4
+ import { help } from "@cloud-cli/cli";
4
5
 
5
6
  const nameRe = /^[a-z][a-z0-9-]+$/;
6
7
  const isValidName = (name) => nameRe.test(name);
7
- const invalidNameError = new Error('Invalid name');
8
+ const invalidNameError = new Error("Invalid name");
9
+ const readOptions = (options: NameOption) => {
10
+ options.name ||= options._?.[0];
11
+ if (!isValidName(options.name)) {
12
+ throw invalidNameError;
13
+ }
14
+ };
15
+
16
+ interface Options {
17
+ _?: string[];
18
+ }
8
19
 
9
- interface NameOption {
20
+ interface NameOption extends Options {
10
21
  name: string;
11
22
  }
12
23
 
13
- interface NameAndPathOption {
24
+ interface NameAndPathOption extends Options {
14
25
  name: string;
15
26
  path?: string;
16
27
  }
17
28
 
18
29
  async function list() {
19
- const output = await exec('docker', ['volume', 'ls', '--format={{.Name}}']);
30
+ const output = await exec("docker", ["volume", "ls", "--format={{.Name}}"]);
20
31
 
21
32
  if (!output.ok) {
22
- throw new Error('Unable to list volumes');
33
+ throw new Error("Unable to list volumes");
23
34
  }
24
35
 
25
- return output.stdout.trim().split('\n');
36
+ return output.stdout.trim().split("\n");
26
37
  }
27
38
 
28
39
  async function getVolumeMountpoint(name): Promise<string> {
29
- const output = await exec('docker', ['volume', 'inspect', name]);
40
+ const output = await exec("docker", ["volume", "inspect", name]);
30
41
  const volumes = JSON.parse(output.stdout);
31
42
 
32
43
  if (!volumes.length) {
33
- throw new Error('Volume not found');
44
+ throw new Error("Volume not found");
34
45
  }
35
46
 
36
47
  return volumes[0].Mountpoint;
37
48
  }
38
49
 
39
50
  async function ls(options: NameAndPathOption) {
40
- const { name, path = '' } = options;
51
+ readOptions(options);
52
+ const { name, path = "" } = options;
41
53
  const root = await getVolumeMountpoint(name);
42
- const files = await exec('ls', ['-1', join(root, path)]);
54
+ const files = await exec("ls", ["-1", join(root, path)]);
43
55
 
44
- return files.stdout.trim().split('\n').filter(Boolean);
56
+ return files.stdout.trim().split("\n").filter(Boolean);
45
57
  }
46
58
 
47
59
  async function fixPermissions(options: NameOption) {
60
+ readOptions(options);
48
61
  const { name } = options;
49
62
  const root = await getVolumeMountpoint(name);
50
- const result = await exec('chmod', ['-R', 'a+w', root]);
63
+ const result = await exec("chmod", ["-R", "a+w", root]);
51
64
 
52
65
  return result.ok;
53
66
  }
54
67
 
55
68
  async function rm(options: NameAndPathOption) {
69
+ readOptions(options);
56
70
  const { name, path } = options;
57
71
 
58
72
  if (!path) {
59
- throw new Error('Path not specified');
73
+ throw new Error("Path not specified");
60
74
  }
61
75
 
62
76
  const root = await getVolumeMountpoint(name);
63
- const result = await exec('rm', ['-r', join(root, path)]);
77
+ const result = await exec("rm", ["-r", join(root, path)]);
64
78
 
65
79
  return result.ok;
66
80
  }
67
81
 
68
82
  async function cat(options: NameAndPathOption) {
83
+ readOptions(options);
69
84
  const { name, path } = options;
70
85
 
71
86
  if (!path) {
72
- throw new Error('Path not specified');
87
+ throw new Error("Path not specified");
73
88
  }
74
89
 
75
90
  const root = await getVolumeMountpoint(name);
76
- return await readFile(join(root, path), 'utf8');
91
+ return await readFile(join(root, path), "utf8");
77
92
  }
78
93
 
79
94
  async function show(options: NameOption) {
80
- if (!isValidName(options.name)) {
81
- throw invalidNameError;
82
- }
95
+ readOptions(options);
83
96
 
84
- const output = await exec('docker', ['volume', 'inspect', options.name]);
97
+ const output = await exec("docker", ["volume", "inspect", options.name]);
85
98
  const list = JSON.parse(output.stdout);
86
99
 
87
100
  if (!list.length) {
88
- throw new Error('Volume not found');
101
+ throw new Error("Volume not found");
89
102
  }
90
103
 
91
104
  const { Name, Labels, CreatedAt } = list[0];
@@ -98,36 +111,57 @@ async function show(options: NameOption) {
98
111
  }
99
112
 
100
113
  async function add(options: NameOption) {
101
- if (!isValidName(options.name)) {
102
- throw invalidNameError;
103
- }
104
-
105
- const output = await exec('docker', ['volume', 'create', options.name]);
114
+ readOptions(options);
115
+ const output = await exec("docker", ["volume", "create", options.name]);
106
116
 
107
117
  if (output.ok) {
118
+ await fixPermissions(options);
108
119
  return true;
109
120
  }
110
121
 
111
- throw new Error('Unable to create volume');
122
+ throw new Error("Unable to create volume");
112
123
  }
113
124
 
114
125
  async function remove(options: NameOption) {
115
- if (!isValidName(options.name)) {
116
- throw invalidNameError;
117
- }
118
-
119
- const output = await exec('docker', ['volume', 'rm', options.name]);
126
+ readOptions(options);
127
+ const output = await exec("docker", ["volume", "rm", options.name]);
120
128
 
121
129
  if (output.ok) {
122
130
  return true;
123
131
  }
124
132
 
125
- throw new Error('Unable to remove volume');
133
+ throw new Error("Unable to remove volume");
126
134
  }
127
135
 
128
136
  async function prune() {
129
- await exec('docker', ['volume', 'prune']);
130
- return '';
137
+ await exec("docker", ["volume", "prune"]);
138
+ return "";
131
139
  }
132
140
 
133
- export default { add, remove, list, show, prune, ls, rm, cat, fixPermissions };
141
+ export default {
142
+ add,
143
+ remove,
144
+ list,
145
+ show,
146
+ prune,
147
+ ls,
148
+ rm,
149
+ cat,
150
+ fixPermissions,
151
+ [help]: () => `Manage Docker volumes
152
+
153
+ Available commands:
154
+ vm.add --name <name> - Create a new Docker volume (name must be lowercase alphanumeric with hyphens)
155
+ vm.remove --name <name> - Remove a Docker volume
156
+ vm.list - List all Docker volumes
157
+ vm.ls --name <name> [--path <path>] - List files in a volume
158
+ vm.cat --name <name> --path <path> - Read a file from a volume
159
+ vm.show --name <name> - Show volume details
160
+ vm.fixPermissions --name <name> - Fix permissions on a volume
161
+ vm.rm --name <name> --path <path> - Remove a file or directory from a volume
162
+ vm.prune - Remove all unused Docker volumes
163
+
164
+ Options:
165
+ name - Volume name (lowercase alphanumeric with hyphens)
166
+ path - Path within the volume`,
167
+ };