8bitsapps-gcp-utils 1.0.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.
- package/GCPUtilities/Network.js +104 -0
- package/GCPUtilities/Storage.js +185 -0
- package/GCPUtilities/index.js +9 -0
- package/README.md +126 -0
- package/commands/index.js +14 -0
- package/commands/init.js +50 -0
- package/commands/storageNavigator.js +282 -0
- package/commands/updateFirewall.js +16 -0
- package/gcpUtils.js +192 -0
- package/myIpToFirewallRule.js +44 -0
- package/package.json +49 -0
- package/utils/configLoader.js +24 -0
- package/utils/paths.js +77 -0
- package/utils/prompts/listWithEscape.js +50 -0
- package/utils/settings.js +53 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
const { JWT } = require("google-auth-library");
|
|
2
|
+
const axios = require("axios");
|
|
3
|
+
const compute = require("@google-cloud/compute");
|
|
4
|
+
const fs = require("fs/promises");
|
|
5
|
+
const { getConfigPath, getCredentialsPath } = require("../utils/paths.js");
|
|
6
|
+
|
|
7
|
+
class Network {
|
|
8
|
+
constructor(configName) {
|
|
9
|
+
this.configurationName = configName;
|
|
10
|
+
this.configuration = null;
|
|
11
|
+
this.credentials = null;
|
|
12
|
+
this.authClient = null;
|
|
13
|
+
this.computeClient = null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async loadConfiguration() {
|
|
17
|
+
if (this.configuration) {
|
|
18
|
+
return; // Already loaded
|
|
19
|
+
}
|
|
20
|
+
if (!this.configurationName)
|
|
21
|
+
throw new Error("Missing configuration name.");
|
|
22
|
+
//
|
|
23
|
+
try {
|
|
24
|
+
const configFileName = getConfigPath(this.configurationName);
|
|
25
|
+
console.log(`Loading configuration: ${configFileName}.`);
|
|
26
|
+
this.configuration = JSON.parse(await fs.readFile(configFileName, "utf8"));
|
|
27
|
+
//
|
|
28
|
+
const credentialsFileName = getCredentialsPath(this.configuration.credentialsFile);
|
|
29
|
+
console.log(`Loading credentials: ${credentialsFileName}.`);
|
|
30
|
+
this.credentials = JSON.parse(await fs.readFile(credentialsFileName, "utf8"));
|
|
31
|
+
//
|
|
32
|
+
this.authClient = new JWT({
|
|
33
|
+
email: this.credentials?.client_email,
|
|
34
|
+
key: this.credentials?.private_key,
|
|
35
|
+
scopes: ["https://www.googleapis.com/auth/cloud-platform"],
|
|
36
|
+
});
|
|
37
|
+
this.computeClient = new compute.FirewallsClient({ auth: this.authClient });
|
|
38
|
+
}
|
|
39
|
+
catch (ex) {
|
|
40
|
+
console.error(`Error while reading the config file: ${ex}.`);
|
|
41
|
+
throw ex;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
unloadConfiguration() {
|
|
46
|
+
this.configuration = null;
|
|
47
|
+
this.credentials = null;
|
|
48
|
+
this.authClient = null;
|
|
49
|
+
this.computeClient = null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async getPublicIP() {
|
|
53
|
+
try {
|
|
54
|
+
const res = await axios.get("https://api.ipify.org?format=json");
|
|
55
|
+
return res.data.ip;
|
|
56
|
+
} catch (error) {
|
|
57
|
+
console.error("Error getting public IP:", error);
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async updateFirewall(options) {
|
|
63
|
+
await this.loadConfiguration();
|
|
64
|
+
//
|
|
65
|
+
const projectId = options?.projectId ?? this.configuration?.defaultProjectId;
|
|
66
|
+
const firewallRule = options?.firewallRule ?? this.configuration?.defaultFirewallRule;
|
|
67
|
+
const fixedIPAddresses = options?.fixedIPAddresses ?? this.configuration?.defaultFixedIPAddresses;
|
|
68
|
+
//
|
|
69
|
+
if (!projectId || !firewallRule) {
|
|
70
|
+
throw new Error("Both projectId and firewallRule must be specified either in options or in the configuration file.");
|
|
71
|
+
}
|
|
72
|
+
//
|
|
73
|
+
const ip = await this.getPublicIP();
|
|
74
|
+
const ipCidr = `${ip}/32`;
|
|
75
|
+
//
|
|
76
|
+
try {
|
|
77
|
+
const [rule] = await this.computeClient.get({
|
|
78
|
+
project: projectId,
|
|
79
|
+
firewall: firewallRule,
|
|
80
|
+
});
|
|
81
|
+
//
|
|
82
|
+
const updatedRule = {
|
|
83
|
+
...rule,
|
|
84
|
+
sourceRanges: [...(fixedIPAddresses || []), ipCidr],
|
|
85
|
+
};
|
|
86
|
+
//
|
|
87
|
+
const [operation] = await this.computeClient.patch({
|
|
88
|
+
project: projectId,
|
|
89
|
+
firewall: firewallRule,
|
|
90
|
+
firewallResource: updatedRule,
|
|
91
|
+
});
|
|
92
|
+
//
|
|
93
|
+
console.log("Firewall updated. Operation:", operation.name);
|
|
94
|
+
} catch (err) {
|
|
95
|
+
if (err.code === 404) {
|
|
96
|
+
console.log("Rule not found!");
|
|
97
|
+
} else {
|
|
98
|
+
console.error("Error:", err);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
module.exports = Network;
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
const fs = require("fs/promises");
|
|
2
|
+
const { Storage: GoogleCloudStorage } = require("@google-cloud/storage");
|
|
3
|
+
const { getConfigPath, getCredentialsPath } = require("../utils/paths.js");
|
|
4
|
+
|
|
5
|
+
class Storage {
|
|
6
|
+
constructor(configName) {
|
|
7
|
+
this.configurationName = configName;
|
|
8
|
+
this.configuration = null;
|
|
9
|
+
this.credentials = null;
|
|
10
|
+
this.storage = null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async loadConfiguration() {
|
|
14
|
+
if (this.configuration) {
|
|
15
|
+
return; // Already loaded.
|
|
16
|
+
}
|
|
17
|
+
if (!this.configurationName)
|
|
18
|
+
throw new Error("Missing configuration name.");
|
|
19
|
+
//
|
|
20
|
+
try {
|
|
21
|
+
const configFileName = getConfigPath(this.configurationName);
|
|
22
|
+
console.log(`Loading configuration: ${configFileName}.`);
|
|
23
|
+
this.configuration = JSON.parse(await fs.readFile(configFileName, "utf8"));
|
|
24
|
+
//
|
|
25
|
+
const credentialsFileName = getCredentialsPath(this.configuration.credentialsFile);
|
|
26
|
+
console.log(`Loading credentials: ${credentialsFileName}.`);
|
|
27
|
+
this.credentials = JSON.parse(await fs.readFile(credentialsFileName, "utf8"));
|
|
28
|
+
//
|
|
29
|
+
this.storage = new GoogleCloudStorage({
|
|
30
|
+
credentials: this.credentials,
|
|
31
|
+
projectId: this.configuration.defaultProjectId
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
catch (ex) {
|
|
35
|
+
console.error(`Error while reading the config file: ${ex}.`);
|
|
36
|
+
throw ex;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
unloadConfiguration() {
|
|
41
|
+
this.configuration = null;
|
|
42
|
+
this.credentials = null;
|
|
43
|
+
this.storage = null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
resolvePath(path) {
|
|
47
|
+
if (path[0] !== "/") {
|
|
48
|
+
return `${process.cwd()}/${path}`;
|
|
49
|
+
}
|
|
50
|
+
return path;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
generateUniquePath(path) {
|
|
54
|
+
const uid = new Date().valueOf();
|
|
55
|
+
const lastDotPos = path.lastIndexOf(".");
|
|
56
|
+
if (lastDotPos < 0) {
|
|
57
|
+
return `${path}-${uid}`;
|
|
58
|
+
}
|
|
59
|
+
return `${path.substring(0, lastDotPos)}-${uid}${path.substring(lastDotPos)}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async upload(pathFile, pathCloud, options) {
|
|
63
|
+
await this.loadConfiguration();
|
|
64
|
+
//
|
|
65
|
+
if (!pathFile || !pathCloud) {
|
|
66
|
+
throw new Error("Must specify both the path of the local file and the cloud path on the bucket.");
|
|
67
|
+
}
|
|
68
|
+
//
|
|
69
|
+
const bucket = options?.bucket ?? this.configuration?.defaultBucket;
|
|
70
|
+
if (!bucket) {
|
|
71
|
+
throw new Error("Bucket must be specified either in options or in the configuration file.");
|
|
72
|
+
}
|
|
73
|
+
//
|
|
74
|
+
const resolvedPathFile = this.resolvePath(pathFile);
|
|
75
|
+
const uniquePathCloud = this.generateUniquePath(pathCloud);
|
|
76
|
+
//
|
|
77
|
+
try {
|
|
78
|
+
await this.storage.bucket(bucket).upload(resolvedPathFile, {
|
|
79
|
+
destination: uniquePathCloud,
|
|
80
|
+
resumable: false,
|
|
81
|
+
});
|
|
82
|
+
console.log(`File uploaded to ${bucket}/${uniquePathCloud}.`);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
throw new Error(`Error uploading file ${resolvedPathFile} to GCloud ${uniquePathCloud}: ${err}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
//
|
|
88
|
+
/**
|
|
89
|
+
* Returns the list of configured buckets.
|
|
90
|
+
* @returns {Array<{name: string, displayName: string}>} List of buckets.
|
|
91
|
+
*/
|
|
92
|
+
async getBuckets() {
|
|
93
|
+
await this.loadConfiguration();
|
|
94
|
+
//
|
|
95
|
+
if (this.configuration.buckets && this.configuration.buckets.length > 0) {
|
|
96
|
+
// Normalize buckets to objects with name and displayName.
|
|
97
|
+
return this.configuration.buckets.map(b => {
|
|
98
|
+
if (typeof b === "string") {
|
|
99
|
+
return { name: b, displayName: b };
|
|
100
|
+
}
|
|
101
|
+
return b;
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
//
|
|
105
|
+
// Fallback to defaultBucket if buckets array is not defined.
|
|
106
|
+
if (this.configuration.defaultBucket) {
|
|
107
|
+
return [{ name: this.configuration.defaultBucket, displayName: this.configuration.defaultBucket }];
|
|
108
|
+
}
|
|
109
|
+
//
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
//
|
|
113
|
+
/**
|
|
114
|
+
* Lists objects in a bucket at the given prefix.
|
|
115
|
+
* @param {string} bucketName - The bucket name.
|
|
116
|
+
* @param {string} prefix - The folder prefix (empty for root).
|
|
117
|
+
* @returns {Promise<{folders: string[], files: Array<{name: string, size: number}>}>} Folders and files.
|
|
118
|
+
*/
|
|
119
|
+
async listObjects(bucketName, prefix = "") {
|
|
120
|
+
await this.loadConfiguration();
|
|
121
|
+
//
|
|
122
|
+
const bucket = this.storage.bucket(bucketName);
|
|
123
|
+
const [files] = await bucket.getFiles({
|
|
124
|
+
prefix: prefix,
|
|
125
|
+
delimiter: "/",
|
|
126
|
+
autoPaginate: false
|
|
127
|
+
});
|
|
128
|
+
//
|
|
129
|
+
// Get folder prefixes from API response.
|
|
130
|
+
const [, , apiResponse] = await bucket.getFiles({
|
|
131
|
+
prefix: prefix,
|
|
132
|
+
delimiter: "/"
|
|
133
|
+
});
|
|
134
|
+
//
|
|
135
|
+
const folders = apiResponse?.prefixes || [];
|
|
136
|
+
//
|
|
137
|
+
// Filter out the prefix itself and get only direct files.
|
|
138
|
+
const directFiles = files
|
|
139
|
+
.filter(file => {
|
|
140
|
+
const relativePath = file.name.slice(prefix.length);
|
|
141
|
+
return relativePath && !relativePath.includes("/");
|
|
142
|
+
})
|
|
143
|
+
.map(file => ({
|
|
144
|
+
name: file.name,
|
|
145
|
+
size: parseInt(file.metadata.size, 10) || 0
|
|
146
|
+
}));
|
|
147
|
+
//
|
|
148
|
+
return { folders, files: directFiles };
|
|
149
|
+
}
|
|
150
|
+
//
|
|
151
|
+
/**
|
|
152
|
+
* Downloads a file from the bucket to a local path.
|
|
153
|
+
* @param {string} bucketName - The bucket name.
|
|
154
|
+
* @param {string} remotePath - The remote file path.
|
|
155
|
+
* @param {string} localPath - The local destination path.
|
|
156
|
+
*/
|
|
157
|
+
async downloadFile(bucketName, remotePath, localPath) {
|
|
158
|
+
await this.loadConfiguration();
|
|
159
|
+
//
|
|
160
|
+
const bucket = this.storage.bucket(bucketName);
|
|
161
|
+
const file = bucket.file(remotePath);
|
|
162
|
+
//
|
|
163
|
+
await file.download({ destination: localPath });
|
|
164
|
+
}
|
|
165
|
+
//
|
|
166
|
+
/**
|
|
167
|
+
* Uploads a file to the bucket.
|
|
168
|
+
* @param {string} bucketName - The bucket name.
|
|
169
|
+
* @param {string} localPath - The local file path.
|
|
170
|
+
* @param {string} remotePath - The remote destination path.
|
|
171
|
+
*/
|
|
172
|
+
async uploadFile(bucketName, localPath, remotePath) {
|
|
173
|
+
await this.loadConfiguration();
|
|
174
|
+
//
|
|
175
|
+
const resolvedPath = this.resolvePath(localPath);
|
|
176
|
+
const bucket = this.storage.bucket(bucketName);
|
|
177
|
+
//
|
|
178
|
+
await bucket.upload(resolvedPath, {
|
|
179
|
+
destination: remotePath,
|
|
180
|
+
resumable: false
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
module.exports = Storage;
|
package/README.md
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# GCP Utils
|
|
2
|
+
|
|
3
|
+
Interactive CLI tool for managing Google Cloud Platform (GCP) services, including firewall and storage operations.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
### Update Firewall with current IP
|
|
8
|
+
|
|
9
|
+
Automatically updates a GCP firewall rule with your current public IP address. Useful for development environments with dynamic IPs that need access to firewall-protected resources.
|
|
10
|
+
|
|
11
|
+
- Automatically detects current public IP.
|
|
12
|
+
- Preserves configured fixed IP addresses.
|
|
13
|
+
- Adds current IP to the firewall rule's sourceRanges.
|
|
14
|
+
|
|
15
|
+
### Browse and manage GCS storage
|
|
16
|
+
|
|
17
|
+
Interactive navigator for Google Cloud Storage:
|
|
18
|
+
|
|
19
|
+
- Browse buckets and folders.
|
|
20
|
+
- View files with size information.
|
|
21
|
+
- Download files to current directory.
|
|
22
|
+
- Upload local files to GCS.
|
|
23
|
+
- Support for multiple buckets per configuration.
|
|
24
|
+
|
|
25
|
+
### Initialize configuration
|
|
26
|
+
|
|
27
|
+
Sets up the global configuration directory (`~/.8bitsapps-gcp-utils/`) with the required structure.
|
|
28
|
+
|
|
29
|
+
## Requirements
|
|
30
|
+
|
|
31
|
+
- **Node.js**: >= 18.0.0 (Node.js 22 LTS recommended)
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
### Global installation (recommended)
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npm install -g 8bitsapps-gcp-utils
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
After installation, the `gcpUtils` command will be available globally.
|
|
42
|
+
|
|
43
|
+
### Install from source
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
git clone https://github.com/giuseppelanzi-8bitsapps-gcp-utils.git
|
|
47
|
+
cd 8bitsapps-gcp-utils
|
|
48
|
+
npm install
|
|
49
|
+
npm link
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The `npm link` command makes `gcpUtils` available globally. To remove:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
npm unlink
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Configuration
|
|
59
|
+
|
|
60
|
+
### First run
|
|
61
|
+
|
|
62
|
+
On first run, the tool will prompt you to initialize:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
gcpUtils
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
This creates the following structure:
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
~/.8bitsapps-gcp-utils/
|
|
72
|
+
├── configurations/
|
|
73
|
+
└── credentials/
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Configuration file
|
|
77
|
+
|
|
78
|
+
Create a JSON file in `~/.8bitsapps-gcp-utils/configurations/` (e.g., `gcp-options-myproject.json`):
|
|
79
|
+
|
|
80
|
+
```json
|
|
81
|
+
{
|
|
82
|
+
"credentialsFile": "my-service-account.json",
|
|
83
|
+
"defaultProjectId": "my-gcp-project-id",
|
|
84
|
+
"defaultFirewallRule": "allow-my-ip",
|
|
85
|
+
"defaultFixedIPAddresses": ["203.0.113.10/32"],
|
|
86
|
+
"buckets": [
|
|
87
|
+
{ "name": "my-bucket-prod", "displayName": "Production" },
|
|
88
|
+
{ "name": "my-bucket-dev", "displayName": "Development" }
|
|
89
|
+
],
|
|
90
|
+
"defaultBucket": "my-bucket-dev"
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
| Field | Description |
|
|
95
|
+
|-------|-------------|
|
|
96
|
+
| `credentialsFile` | GCP service account JSON filename (stored in `credentials/`). |
|
|
97
|
+
| `defaultProjectId` | GCP project ID. |
|
|
98
|
+
| `defaultFirewallRule` | Firewall rule name to update. |
|
|
99
|
+
| `defaultFixedIPAddresses` | Array of fixed IPs to always keep in the rule. |
|
|
100
|
+
| `buckets` | Array of available buckets (optional). |
|
|
101
|
+
| `defaultBucket` | Default bucket for storage operations. |
|
|
102
|
+
|
|
103
|
+
### GCP Credentials
|
|
104
|
+
|
|
105
|
+
Download your service account JSON from GCP Console and save it in `~/.8bitsapps-gcp-utils/credentials/`.
|
|
106
|
+
|
|
107
|
+
Required roles:
|
|
108
|
+
|
|
109
|
+
- **Firewall**: `Compute Security Admin` or `Compute Network Admin`
|
|
110
|
+
- **Storage**: `Storage Object Admin`
|
|
111
|
+
|
|
112
|
+
## Usage
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
gcpUtils
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Navigate menus with arrow keys. Press `ESC` to go back or exit.
|
|
119
|
+
|
|
120
|
+
## License
|
|
121
|
+
|
|
122
|
+
ISC
|
|
123
|
+
|
|
124
|
+
## Author
|
|
125
|
+
|
|
126
|
+
**8BitsApps** - https://8bitsapps.com
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const updateFirewall = require("./updateFirewall.js");
|
|
2
|
+
const storageNavigator = require("./storageNavigator.js");
|
|
3
|
+
const init = require("./init.js");
|
|
4
|
+
//
|
|
5
|
+
/**
|
|
6
|
+
* Registry of available commands.
|
|
7
|
+
*/
|
|
8
|
+
const commands = [
|
|
9
|
+
updateFirewall,
|
|
10
|
+
storageNavigator,
|
|
11
|
+
init
|
|
12
|
+
];
|
|
13
|
+
//
|
|
14
|
+
module.exports = commands;
|
package/commands/init.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
const fs = require("fs/promises");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const chalk = require("chalk");
|
|
4
|
+
const { getGlobalConfigDir } = require("../utils/paths.js");
|
|
5
|
+
//
|
|
6
|
+
/**
|
|
7
|
+
* Init command - initializes the global configuration directory.
|
|
8
|
+
*/
|
|
9
|
+
const command = {
|
|
10
|
+
name: "init",
|
|
11
|
+
description: "Initialize global configuration directory",
|
|
12
|
+
async execute() {
|
|
13
|
+
const configDir = getGlobalConfigDir();
|
|
14
|
+
const configurationsDir = path.join(configDir, "configurations");
|
|
15
|
+
const credentialsDir = path.join(configDir, "credentials");
|
|
16
|
+
//
|
|
17
|
+
console.log(chalk.cyan("Initializing gcp-utils configuration..."));
|
|
18
|
+
console.log(`Config directory: ${configDir}`);
|
|
19
|
+
//
|
|
20
|
+
// Create directories if they don't exist.
|
|
21
|
+
await fs.mkdir(configurationsDir, { recursive: true });
|
|
22
|
+
await fs.mkdir(credentialsDir, { recursive: true });
|
|
23
|
+
//
|
|
24
|
+
// Create example configuration file.
|
|
25
|
+
const exampleConfig = {
|
|
26
|
+
credentialsFile: "gcp-credentials-example.json",
|
|
27
|
+
defaultProjectId: "YOUR_PROJECT_ID",
|
|
28
|
+
defaultFirewallRule: "YOUR_FIREWALL_RULE_NAME",
|
|
29
|
+
defaultFixedIPAddresses: ["8.8.8.8/32"],
|
|
30
|
+
defaultBucket: "YOUR_BUCKET_NAME"
|
|
31
|
+
};
|
|
32
|
+
const exampleConfigPath = path.join(configurationsDir, "gcp-options-example.json");
|
|
33
|
+
//
|
|
34
|
+
try {
|
|
35
|
+
await fs.access(exampleConfigPath);
|
|
36
|
+
console.log(chalk.yellow("Example config already exists."));
|
|
37
|
+
} catch {
|
|
38
|
+
await fs.writeFile(exampleConfigPath, JSON.stringify(exampleConfig, null, 2));
|
|
39
|
+
console.log(chalk.green(`Created: ${exampleConfigPath}`));
|
|
40
|
+
}
|
|
41
|
+
//
|
|
42
|
+
console.log(chalk.cyan("\nSetup complete!"));
|
|
43
|
+
console.log("\nNext steps:");
|
|
44
|
+
console.log(`1. Add your GCP credentials JSON to: ${credentialsDir}/`);
|
|
45
|
+
console.log(`2. Edit configuration files in: ${configurationsDir}/`);
|
|
46
|
+
console.log("3. Run: gcpUtils");
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
//
|
|
50
|
+
module.exports = command;
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
const path = require("path");
|
|
2
|
+
const inquirer = require("inquirer");
|
|
3
|
+
const chalk = require("chalk");
|
|
4
|
+
const Storage = require("../GCPUtilities/Storage.js");
|
|
5
|
+
const ListWithEscapePrompt = require("../utils/prompts/listWithEscape.js");
|
|
6
|
+
const { getSettings } = require("../utils/settings.js");
|
|
7
|
+
//
|
|
8
|
+
// Register custom prompt.
|
|
9
|
+
inquirer.registerPrompt("listWithEscape", ListWithEscapePrompt);
|
|
10
|
+
//
|
|
11
|
+
/**
|
|
12
|
+
* Formats file size in human readable format.
|
|
13
|
+
* @param {number} bytes - Size in bytes.
|
|
14
|
+
* @returns {string} Formatted size.
|
|
15
|
+
*/
|
|
16
|
+
function formatSize(bytes) {
|
|
17
|
+
if (bytes === 0) return "0 B";
|
|
18
|
+
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
19
|
+
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
20
|
+
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
|
|
21
|
+
}
|
|
22
|
+
//
|
|
23
|
+
/**
|
|
24
|
+
* Extracts the display name from a full path.
|
|
25
|
+
* @param {string} fullPath - The full path.
|
|
26
|
+
* @param {string} prefix - The current prefix.
|
|
27
|
+
* @returns {string} The display name.
|
|
28
|
+
*/
|
|
29
|
+
function getDisplayName(fullPath, prefix) {
|
|
30
|
+
return fullPath.slice(prefix.length);
|
|
31
|
+
}
|
|
32
|
+
//
|
|
33
|
+
/**
|
|
34
|
+
* Shows bucket selection menu.
|
|
35
|
+
* @param {Array<{name: string, displayName: string}>} buckets - List of buckets.
|
|
36
|
+
* @returns {Promise<string|null>} Selected bucket name or null if ESC pressed.
|
|
37
|
+
*/
|
|
38
|
+
async function selectBucket(buckets) {
|
|
39
|
+
if (buckets.length === 1) {
|
|
40
|
+
return buckets[0].name;
|
|
41
|
+
}
|
|
42
|
+
//
|
|
43
|
+
const choices = buckets.map((b, i) => ({
|
|
44
|
+
name: `${i + 1}. ${b.displayName} (${b.name})`,
|
|
45
|
+
value: b.name
|
|
46
|
+
}));
|
|
47
|
+
//
|
|
48
|
+
const { selected } = await inquirer.prompt([{
|
|
49
|
+
type: "listWithEscape",
|
|
50
|
+
name: "selected",
|
|
51
|
+
message: "Select bucket (ESC exit):",
|
|
52
|
+
choices,
|
|
53
|
+
enableBack: false
|
|
54
|
+
}]);
|
|
55
|
+
//
|
|
56
|
+
return selected;
|
|
57
|
+
}
|
|
58
|
+
//
|
|
59
|
+
/**
|
|
60
|
+
* Shows folder/file navigation menu.
|
|
61
|
+
* @param {string} bucketName - Current bucket.
|
|
62
|
+
* @param {string} currentPath - Current path prefix.
|
|
63
|
+
* @param {{folders: string[], files: Array<{name: string, size: number}>}} contents - Folder contents.
|
|
64
|
+
* @param {{maxItems: number, backEnabled: boolean}} options - Menu options.
|
|
65
|
+
* @returns {Promise<{action: string, value: string}|null>} Selected action or null if ESC pressed.
|
|
66
|
+
*/
|
|
67
|
+
async function showNavigationMenu(bucketName, currentPath, contents, options) {
|
|
68
|
+
const { maxItems, backEnabled } = options;
|
|
69
|
+
const displayPath = currentPath || "/";
|
|
70
|
+
const choices = [];
|
|
71
|
+
let itemCount = 0;
|
|
72
|
+
let truncated = false;
|
|
73
|
+
//
|
|
74
|
+
// Add folders (limited by maxItems).
|
|
75
|
+
for (const folder of contents.folders) {
|
|
76
|
+
if (itemCount >= maxItems) {
|
|
77
|
+
truncated = true;
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
const displayName = getDisplayName(folder, currentPath);
|
|
81
|
+
choices.push({
|
|
82
|
+
name: chalk.blue(`[D] ${displayName}`),
|
|
83
|
+
value: { action: "folder", value: folder }
|
|
84
|
+
});
|
|
85
|
+
itemCount++;
|
|
86
|
+
}
|
|
87
|
+
//
|
|
88
|
+
// Add files (limited by maxItems).
|
|
89
|
+
for (const file of contents.files) {
|
|
90
|
+
if (itemCount >= maxItems) {
|
|
91
|
+
truncated = true;
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
const displayName = getDisplayName(file.name, currentPath);
|
|
95
|
+
const sizeStr = formatSize(file.size);
|
|
96
|
+
choices.push({
|
|
97
|
+
name: `[F] ${displayName} (${sizeStr})`,
|
|
98
|
+
value: { action: "file", value: file.name }
|
|
99
|
+
});
|
|
100
|
+
itemCount++;
|
|
101
|
+
}
|
|
102
|
+
//
|
|
103
|
+
// Add separator and upload option.
|
|
104
|
+
if (choices.length > 0) {
|
|
105
|
+
choices.push(new inquirer.Separator("─────────────"));
|
|
106
|
+
}
|
|
107
|
+
if (truncated) {
|
|
108
|
+
choices.push(new inquirer.Separator(chalk.yellow(`(showing first ${maxItems} items)`)));
|
|
109
|
+
}
|
|
110
|
+
choices.push({
|
|
111
|
+
name: chalk.green("Upload file here"),
|
|
112
|
+
value: { action: "upload", value: currentPath }
|
|
113
|
+
});
|
|
114
|
+
//
|
|
115
|
+
const message = backEnabled
|
|
116
|
+
? `${bucketName}:${displayPath} (← back, ESC exit)`
|
|
117
|
+
: `${bucketName}:${displayPath} (ESC exit)`;
|
|
118
|
+
//
|
|
119
|
+
const { selected } = await inquirer.prompt([{
|
|
120
|
+
type: "listWithEscape",
|
|
121
|
+
name: "selected",
|
|
122
|
+
message,
|
|
123
|
+
choices,
|
|
124
|
+
enableBack: backEnabled
|
|
125
|
+
}]);
|
|
126
|
+
//
|
|
127
|
+
return selected;
|
|
128
|
+
}
|
|
129
|
+
//
|
|
130
|
+
/**
|
|
131
|
+
* Prompts for local file path to upload.
|
|
132
|
+
* @returns {Promise<string|null>} Local file path or null if cancelled.
|
|
133
|
+
*/
|
|
134
|
+
async function promptUploadPath() {
|
|
135
|
+
const { filePath } = await inquirer.prompt([{
|
|
136
|
+
type: "input",
|
|
137
|
+
name: "filePath",
|
|
138
|
+
message: "Enter local file path to upload (or leave empty to cancel):"
|
|
139
|
+
}]);
|
|
140
|
+
//
|
|
141
|
+
return filePath || null;
|
|
142
|
+
}
|
|
143
|
+
//
|
|
144
|
+
/**
|
|
145
|
+
* Shows the operation log section.
|
|
146
|
+
* @param {Array<{type: string, message: string}>} logs - Array of log entries.
|
|
147
|
+
*/
|
|
148
|
+
function showOperationLog(logs) {
|
|
149
|
+
if (logs.length === 0) return;
|
|
150
|
+
//
|
|
151
|
+
console.log(chalk.gray("════════════════════════════════════════"));
|
|
152
|
+
console.log(chalk.gray(" OPERATION LOG"));
|
|
153
|
+
console.log(chalk.gray("════════════════════════════════════════"));
|
|
154
|
+
for (const log of logs) {
|
|
155
|
+
if (log.type === "success") {
|
|
156
|
+
console.log(chalk.green(` ✓ ${log.message}`));
|
|
157
|
+
} else if (log.type === "error") {
|
|
158
|
+
console.log(chalk.red(` ✗ ${log.message}`));
|
|
159
|
+
} else {
|
|
160
|
+
console.log(chalk.gray(` · ${log.message}`));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
console.log(chalk.gray("════════════════════════════════════════\n"));
|
|
164
|
+
}
|
|
165
|
+
//
|
|
166
|
+
/**
|
|
167
|
+
* Storage Navigator command.
|
|
168
|
+
*/
|
|
169
|
+
const command = {
|
|
170
|
+
name: "storageNavigator",
|
|
171
|
+
description: "Browse and manage GCS storage",
|
|
172
|
+
//
|
|
173
|
+
async execute(configName) {
|
|
174
|
+
const storage = new Storage(configName);
|
|
175
|
+
const settings = getSettings();
|
|
176
|
+
const maxItems = settings.storage.maxItems;
|
|
177
|
+
//
|
|
178
|
+
// Get available buckets.
|
|
179
|
+
const buckets = await storage.getBuckets();
|
|
180
|
+
if (buckets.length === 0) {
|
|
181
|
+
console.log(chalk.red("No buckets configured. Add 'buckets' array or 'defaultBucket' to your configuration."));
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
//
|
|
185
|
+
// Select bucket.
|
|
186
|
+
const bucketName = await selectBucket(buckets);
|
|
187
|
+
if (bucketName === null || bucketName?.action === "back") {
|
|
188
|
+
return; // ESC or back arrow pressed.
|
|
189
|
+
}
|
|
190
|
+
//
|
|
191
|
+
// Navigation loop.
|
|
192
|
+
const pathHistory = [""];
|
|
193
|
+
const operationLogs = [];
|
|
194
|
+
//
|
|
195
|
+
while (true) {
|
|
196
|
+
showOperationLog(operationLogs);
|
|
197
|
+
const currentPath = pathHistory[pathHistory.length - 1];
|
|
198
|
+
//
|
|
199
|
+
// List objects at current path.
|
|
200
|
+
process.stdout.write(chalk.yellow(`\n⏳ Listing ${currentPath || "/"}...`));
|
|
201
|
+
let contents;
|
|
202
|
+
try {
|
|
203
|
+
contents = await storage.listObjects(bucketName, currentPath);
|
|
204
|
+
// Clear the "Listing..." line (current line, no move up).
|
|
205
|
+
process.stdout.write("\x1b[2K\x1b[G");
|
|
206
|
+
} catch (err) {
|
|
207
|
+
process.stdout.write("\n");
|
|
208
|
+
console.log(chalk.red(`Error listing objects: ${err.message}`));
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
//
|
|
212
|
+
// Show navigation menu.
|
|
213
|
+
const backEnabled = pathHistory.length > 1;
|
|
214
|
+
const result = await showNavigationMenu(bucketName, currentPath, contents, { maxItems, backEnabled });
|
|
215
|
+
//
|
|
216
|
+
if (result === null) {
|
|
217
|
+
// ESC pressed - exit storage navigator completely.
|
|
218
|
+
const exitDisplayPath = currentPath || "/";
|
|
219
|
+
const exitHint = backEnabled ? "(← back, ESC exit)" : "(ESC exit)";
|
|
220
|
+
process.stdout.write(`\x1b[1A\x1b[2K\x1b[G? ${bucketName}:${exitDisplayPath} ${exitHint} ${chalk.red("<- exit")}`);
|
|
221
|
+
console.log();
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
//
|
|
225
|
+
if (result.action === "back") {
|
|
226
|
+
// Left arrow pressed - go back one level (only possible when backEnabled is true).
|
|
227
|
+
const backDisplayPath = currentPath || "/";
|
|
228
|
+
process.stdout.write(`\x1b[1A\x1b[2K\x1b[G? ${bucketName}:${backDisplayPath} (← back, ESC exit) ${chalk.cyan("<- back")}`);
|
|
229
|
+
pathHistory.pop();
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
//
|
|
233
|
+
switch (result.action) {
|
|
234
|
+
case "folder": {
|
|
235
|
+
// Force newline, then go back up and overwrite inquirer's colored line.
|
|
236
|
+
const folderDisplayName = getDisplayName(result.value, currentPath);
|
|
237
|
+
const folderDisplayPath = currentPath || "/";
|
|
238
|
+
process.stdout.write("\x1b[1A\x1b[2K\x1b[G");
|
|
239
|
+
process.stdout.write(`? ${bucketName}:${folderDisplayPath} (← back, ESC exit) ${chalk.cyan(`[D] ${folderDisplayName}`)}`);
|
|
240
|
+
// Enter folder.
|
|
241
|
+
pathHistory.push(result.value);
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
//
|
|
245
|
+
case "file": {
|
|
246
|
+
// Download file.
|
|
247
|
+
const fileName = path.basename(result.value);
|
|
248
|
+
const localPath = path.join(process.cwd(), fileName);
|
|
249
|
+
//
|
|
250
|
+
console.log(chalk.yellow(`\n⏳ Downloading ${fileName}...`));
|
|
251
|
+
try {
|
|
252
|
+
await storage.downloadFile(bucketName, result.value, localPath);
|
|
253
|
+
operationLogs.push({ type: "success", message: `Downloaded: ${fileName} → ${localPath}` });
|
|
254
|
+
} catch (err) {
|
|
255
|
+
operationLogs.push({ type: "error", message: `Download failed: ${fileName} - ${err.message}` });
|
|
256
|
+
}
|
|
257
|
+
break;
|
|
258
|
+
}
|
|
259
|
+
//
|
|
260
|
+
case "upload": {
|
|
261
|
+
// Upload file.
|
|
262
|
+
const uploadPath = await promptUploadPath();
|
|
263
|
+
if (uploadPath) {
|
|
264
|
+
const uploadFileName = path.basename(uploadPath);
|
|
265
|
+
const remotePath = currentPath + uploadFileName;
|
|
266
|
+
//
|
|
267
|
+
console.log(chalk.yellow(`\n⏳ Uploading ${uploadFileName}...`));
|
|
268
|
+
try {
|
|
269
|
+
await storage.uploadFile(bucketName, uploadPath, remotePath);
|
|
270
|
+
operationLogs.push({ type: "success", message: `Uploaded: ${uploadFileName} → ${bucketName}/${remotePath}` });
|
|
271
|
+
} catch (err) {
|
|
272
|
+
operationLogs.push({ type: "error", message: `Upload failed: ${uploadFileName} - ${err.message}` });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
//
|
|
282
|
+
module.exports = command;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const GCPUtils = require("../GCPUtilities");
|
|
2
|
+
//
|
|
3
|
+
/**
|
|
4
|
+
* Executes the firewall update command.
|
|
5
|
+
* @param {string} configName - Configuration name.
|
|
6
|
+
*/
|
|
7
|
+
async function execute(configName) {
|
|
8
|
+
const networkManager = new GCPUtils.Network(configName);
|
|
9
|
+
await networkManager.updateFirewall();
|
|
10
|
+
}
|
|
11
|
+
//
|
|
12
|
+
module.exports = {
|
|
13
|
+
name: "updateFirewall",
|
|
14
|
+
description: "Update Firewall with current IP",
|
|
15
|
+
execute
|
|
16
|
+
};
|
package/gcpUtils.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const fs = require("fs/promises");
|
|
3
|
+
const inquirer = require("inquirer");
|
|
4
|
+
const chalk = require("chalk");
|
|
5
|
+
const { isLocalMode, getConfigurationsDir } = require("./utils/paths.js");
|
|
6
|
+
const { listConfigurations } = require("./utils/configLoader.js");
|
|
7
|
+
const commands = require("./commands/index.js");
|
|
8
|
+
const ListWithEscapePrompt = require("./utils/prompts/listWithEscape.js");
|
|
9
|
+
//
|
|
10
|
+
// Register custom prompt with ESC support.
|
|
11
|
+
inquirer.registerPrompt("listWithEscape", ListWithEscapePrompt);
|
|
12
|
+
//
|
|
13
|
+
/**
|
|
14
|
+
* Shows the main menu to select a command.
|
|
15
|
+
* @returns {Promise<string>} Selected command name.
|
|
16
|
+
*/
|
|
17
|
+
async function showMainMenu() {
|
|
18
|
+
// Filter out init command from numbered list.
|
|
19
|
+
const regularCommands = commands.filter(cmd => cmd.name !== "init");
|
|
20
|
+
const choices = regularCommands.map((cmd, i) => ({
|
|
21
|
+
name: `${i + 1}. ${cmd.description}`,
|
|
22
|
+
value: cmd.name
|
|
23
|
+
}));
|
|
24
|
+
// Add init option with "." prefix.
|
|
25
|
+
choices.push({ name: ".. Initialize repository", value: "init" });
|
|
26
|
+
//
|
|
27
|
+
const { selected } = await inquirer.prompt([{
|
|
28
|
+
type: "listWithEscape",
|
|
29
|
+
name: "selected",
|
|
30
|
+
message: "Select command (ESC to exit):",
|
|
31
|
+
choices,
|
|
32
|
+
enableBack: false
|
|
33
|
+
}]);
|
|
34
|
+
//
|
|
35
|
+
return selected;
|
|
36
|
+
}
|
|
37
|
+
//
|
|
38
|
+
/**
|
|
39
|
+
* Shows the configuration selection menu.
|
|
40
|
+
* @returns {Promise<string>} Selected configuration name.
|
|
41
|
+
*/
|
|
42
|
+
async function showConfigMenu() {
|
|
43
|
+
const configs = await listConfigurations();
|
|
44
|
+
//
|
|
45
|
+
if (configs.length === 0) {
|
|
46
|
+
console.error(`No configuration found in ${getConfigurationsDir()}/.`);
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
//
|
|
50
|
+
const choices = configs.map((cfg, i) => ({
|
|
51
|
+
name: `${i + 1}. ${cfg}`,
|
|
52
|
+
value: cfg
|
|
53
|
+
}));
|
|
54
|
+
//
|
|
55
|
+
const { selected } = await inquirer.prompt([{
|
|
56
|
+
type: "listWithEscape",
|
|
57
|
+
name: "selected",
|
|
58
|
+
message: "Select configuration (ESC to go back):",
|
|
59
|
+
choices,
|
|
60
|
+
enableBack: false
|
|
61
|
+
}]);
|
|
62
|
+
//
|
|
63
|
+
return selected;
|
|
64
|
+
}
|
|
65
|
+
//
|
|
66
|
+
/**
|
|
67
|
+
* Checks if the configuration directory is initialized.
|
|
68
|
+
* @returns {Promise<boolean>} True if initialized with at least one config.
|
|
69
|
+
*/
|
|
70
|
+
async function isInitialized() {
|
|
71
|
+
try {
|
|
72
|
+
await fs.access(getConfigurationsDir());
|
|
73
|
+
const configs = await listConfigurations();
|
|
74
|
+
return configs.length > 0;
|
|
75
|
+
} catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
//
|
|
80
|
+
/**
|
|
81
|
+
* Waits for user to press ENTER or ESC to continue.
|
|
82
|
+
*/
|
|
83
|
+
async function waitForKeypress() {
|
|
84
|
+
return new Promise((resolve) => {
|
|
85
|
+
const readline = require("readline");
|
|
86
|
+
readline.emitKeypressEvents(process.stdin);
|
|
87
|
+
if (process.stdin.isTTY) {
|
|
88
|
+
process.stdin.setRawMode(true);
|
|
89
|
+
}
|
|
90
|
+
process.stdin.resume();
|
|
91
|
+
//
|
|
92
|
+
console.log(chalk.gray("Press ENTER or ESC to continue..."));
|
|
93
|
+
//
|
|
94
|
+
process.stdin.once("keypress", (_str, _key) => {
|
|
95
|
+
if (process.stdin.isTTY) {
|
|
96
|
+
process.stdin.setRawMode(false);
|
|
97
|
+
}
|
|
98
|
+
process.stdin.pause();
|
|
99
|
+
resolve();
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
//
|
|
104
|
+
/**
|
|
105
|
+
* Shows the application banner.
|
|
106
|
+
*/
|
|
107
|
+
function showBanner() {
|
|
108
|
+
const mode = isLocalMode() ? "local" : "global";
|
|
109
|
+
console.log(
|
|
110
|
+
`=================================================
|
|
111
|
+
${chalk.hex("#FFFFFF")(" █ █ ")} |
|
|
112
|
+
${chalk.hex("#ffff00")(" █ █ ")} |
|
|
113
|
+
${chalk.hex("#fffF00")(" ███████ ")} | ${chalk.hex("#F77B00").bold("GCP Utils")}
|
|
114
|
+
${chalk.hex("#FFCE00")(" ███ █ ███ ")} | by ${chalk.whiteBright.bold("8BitsApps")}
|
|
115
|
+
${chalk.hex("#FFCE00")("█████████████ ")} |
|
|
116
|
+
${chalk.hex("#F77B00")("█ ███████ █ ")} | ${chalk.bgHex("#FFCE00").hex("#000000")("We made app for fun! (ツ)")}
|
|
117
|
+
${chalk.hex("#F77B00")("█ ███████ █ ")} | ${chalk.hex("#FFCE00")("https://8bitsapps.com")}
|
|
118
|
+
${chalk.hex("#E73100")(" █ █ ")} |
|
|
119
|
+
${chalk.hex("#E73100")(" ██ ██ ")} | ${chalk.gray(`Mode:${mode}`)}
|
|
120
|
+
_________________________________________________
|
|
121
|
+
`);
|
|
122
|
+
}
|
|
123
|
+
//
|
|
124
|
+
/**
|
|
125
|
+
* Main entry point.
|
|
126
|
+
*/
|
|
127
|
+
async function main() {
|
|
128
|
+
console.clear();
|
|
129
|
+
// Check if initialized.
|
|
130
|
+
if (!(await isInitialized())) {
|
|
131
|
+
console.log(chalk.yellow("Configuration not found."));
|
|
132
|
+
console.log(`Run ${chalk.cyan("gcpUtils")} after initialization to use the tool.\n`);
|
|
133
|
+
//
|
|
134
|
+
const { shouldInit } = await inquirer.prompt([{
|
|
135
|
+
type: "confirm",
|
|
136
|
+
name: "shouldInit",
|
|
137
|
+
message: "Would you like to initialize now?",
|
|
138
|
+
default: true
|
|
139
|
+
}]);
|
|
140
|
+
//
|
|
141
|
+
if (shouldInit) {
|
|
142
|
+
const initCmd = commands.find(c => c.name === "init");
|
|
143
|
+
await initCmd.execute();
|
|
144
|
+
}
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
//
|
|
148
|
+
while (true) {
|
|
149
|
+
console.clear();
|
|
150
|
+
showBanner();
|
|
151
|
+
const cmdName = await showMainMenu();
|
|
152
|
+
//
|
|
153
|
+
// ESC pressed in main menu - exit.
|
|
154
|
+
if (cmdName === null) {
|
|
155
|
+
console.log("Goodbye!");
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
//
|
|
159
|
+
if (cmdName === "init") {
|
|
160
|
+
const initCmd = commands.find(c => c.name === "init");
|
|
161
|
+
await initCmd.execute();
|
|
162
|
+
console.log("\n");
|
|
163
|
+
await waitForKeypress();
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
//
|
|
167
|
+
const cmd = commands.find(c => c.name === cmdName);
|
|
168
|
+
if (!cmd) {
|
|
169
|
+
console.error(`Command not found: ${cmdName}`);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
//
|
|
173
|
+
const configName = await showConfigMenu();
|
|
174
|
+
//
|
|
175
|
+
// ESC pressed in config menu - go back to main menu.
|
|
176
|
+
if (configName === null) {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
//
|
|
180
|
+
console.log(`\nRunning: ${cmd.description} with config: ${configName}\n`);
|
|
181
|
+
//
|
|
182
|
+
try {
|
|
183
|
+
await cmd.execute(configName);
|
|
184
|
+
console.log("\nCompleted.\n");
|
|
185
|
+
} catch (err) {
|
|
186
|
+
console.error(`\nError: ${err.message}\n`);
|
|
187
|
+
}
|
|
188
|
+
await waitForKeypress();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
//
|
|
192
|
+
main();
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const inquirer = require("inquirer");
|
|
2
|
+
const GCPUtils = require("./GCPUtilities");
|
|
3
|
+
const { listConfigurations } = require("./utils/configLoader.js");
|
|
4
|
+
const { getConfigurationsDir } = require("./utils/paths.js");
|
|
5
|
+
//
|
|
6
|
+
/**
|
|
7
|
+
* Shows configuration selection menu.
|
|
8
|
+
* @returns {Promise<string>} Selected configuration name.
|
|
9
|
+
*/
|
|
10
|
+
async function selectConfiguration() {
|
|
11
|
+
const configs = await listConfigurations();
|
|
12
|
+
//
|
|
13
|
+
if (configs.length === 0) {
|
|
14
|
+
throw new Error(`No configuration found in ${getConfigurationsDir()}/. Run 'gcpUtils' to initialize.`);
|
|
15
|
+
}
|
|
16
|
+
//
|
|
17
|
+
const choices = configs.map((cfg, i) => ({
|
|
18
|
+
name: `${i + 1}. ${cfg}`,
|
|
19
|
+
value: cfg
|
|
20
|
+
}));
|
|
21
|
+
//
|
|
22
|
+
const { selected } = await inquirer.prompt([{
|
|
23
|
+
type: "list",
|
|
24
|
+
name: "selected",
|
|
25
|
+
message: "Select configuration:",
|
|
26
|
+
choices
|
|
27
|
+
}]);
|
|
28
|
+
//
|
|
29
|
+
return selected;
|
|
30
|
+
}
|
|
31
|
+
//
|
|
32
|
+
(async () => {
|
|
33
|
+
let configName = process.argv[2];
|
|
34
|
+
//
|
|
35
|
+
// If no argument provided, show interactive menu.
|
|
36
|
+
if (!configName) {
|
|
37
|
+
console.log("=== Update Firewall ===\n");
|
|
38
|
+
configName = await selectConfiguration();
|
|
39
|
+
console.log();
|
|
40
|
+
}
|
|
41
|
+
//
|
|
42
|
+
const networkManager = new GCPUtils.Network(configName);
|
|
43
|
+
await networkManager.updateFirewall();
|
|
44
|
+
})();
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "8bitsapps-gcp-utils",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "GCP Utility CLI for firewall and storage operations",
|
|
5
|
+
"main": "gcpUtils.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"gcpUtils": "./gcpUtils.js"
|
|
8
|
+
},
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=18.0.0"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"gcpUtils.js",
|
|
14
|
+
"myIpToFirewallRule.js",
|
|
15
|
+
"commands/",
|
|
16
|
+
"utils/",
|
|
17
|
+
"GCPUtilities/"
|
|
18
|
+
],
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/giuseppelanzi/8bitsapps-gcp-utils.git"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"gcp",
|
|
25
|
+
"google-cloud",
|
|
26
|
+
"firewall",
|
|
27
|
+
"cli",
|
|
28
|
+
"utility"
|
|
29
|
+
],
|
|
30
|
+
"author": "Giuseppe Lanzi",
|
|
31
|
+
"license": "ISC",
|
|
32
|
+
"scripts": {
|
|
33
|
+
"test": "eslint .",
|
|
34
|
+
"lint": "eslint .",
|
|
35
|
+
"lint:fix": "eslint . --fix"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"eslint": "^9.18.0",
|
|
39
|
+
"@eslint/js": "^9.18.0",
|
|
40
|
+
"globals": "^15.14.0"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@google-cloud/compute": "^5.2.0",
|
|
44
|
+
"@google-cloud/storage": "^7.16.0",
|
|
45
|
+
"axios": "^1.10.0",
|
|
46
|
+
"chalk": "^4.1.2",
|
|
47
|
+
"inquirer": "^8.2.6"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const fs = require("fs/promises");
|
|
2
|
+
const { getConfigurationsDir } = require("./paths.js");
|
|
3
|
+
//
|
|
4
|
+
const CONFIG_PREFIX = "gcp-options-";
|
|
5
|
+
const CONFIG_SUFFIX = ".json";
|
|
6
|
+
//
|
|
7
|
+
/**
|
|
8
|
+
* Reads configuration names dynamically from the user config directory.
|
|
9
|
+
* @returns {Promise<string[]>} Array of configuration names.
|
|
10
|
+
*/
|
|
11
|
+
async function listConfigurations() {
|
|
12
|
+
const configDir = getConfigurationsDir();
|
|
13
|
+
try {
|
|
14
|
+
const files = await fs.readdir(configDir);
|
|
15
|
+
return files
|
|
16
|
+
.filter(f => f.startsWith(CONFIG_PREFIX) && f.endsWith(CONFIG_SUFFIX))
|
|
17
|
+
.map(f => f.slice(CONFIG_PREFIX.length, -CONFIG_SUFFIX.length))
|
|
18
|
+
.sort();
|
|
19
|
+
} catch {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
//
|
|
24
|
+
module.exports = { listConfigurations };
|
package/utils/paths.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
const os = require("os");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
//
|
|
5
|
+
const APP_NAME = "gcp-utils";
|
|
6
|
+
const LOCAL_CONFIG_DIR = "Configurations";
|
|
7
|
+
const LOCAL_CREDS_DIR = "Credentials";
|
|
8
|
+
//
|
|
9
|
+
/**
|
|
10
|
+
* Determines if local mode should be used.
|
|
11
|
+
* @returns {boolean} True if local Configurations/ folder exists.
|
|
12
|
+
*/
|
|
13
|
+
function isLocalMode() {
|
|
14
|
+
const localConfigPath = path.join(process.cwd(), LOCAL_CONFIG_DIR);
|
|
15
|
+
return fs.existsSync(localConfigPath);
|
|
16
|
+
}
|
|
17
|
+
//
|
|
18
|
+
/**
|
|
19
|
+
* Returns the global configuration directory path.
|
|
20
|
+
* @returns {string} Absolute path to global config directory.
|
|
21
|
+
*/
|
|
22
|
+
function getGlobalConfigDir() {
|
|
23
|
+
const homeDir = os.homedir();
|
|
24
|
+
if (process.platform === "win32") {
|
|
25
|
+
return path.join(process.env.APPDATA || path.join(homeDir, "AppData", "Roaming"), APP_NAME);
|
|
26
|
+
}
|
|
27
|
+
return path.join(homeDir, `.${APP_NAME}`);
|
|
28
|
+
}
|
|
29
|
+
//
|
|
30
|
+
/**
|
|
31
|
+
* Returns the configurations directory path.
|
|
32
|
+
* @returns {string} Absolute path to configurations directory.
|
|
33
|
+
*/
|
|
34
|
+
function getConfigurationsDir() {
|
|
35
|
+
if (isLocalMode()) {
|
|
36
|
+
return path.join(process.cwd(), LOCAL_CONFIG_DIR);
|
|
37
|
+
}
|
|
38
|
+
return path.join(getGlobalConfigDir(), "configurations");
|
|
39
|
+
}
|
|
40
|
+
//
|
|
41
|
+
/**
|
|
42
|
+
* Returns the credentials directory path.
|
|
43
|
+
* @returns {string} Absolute path to credentials directory.
|
|
44
|
+
*/
|
|
45
|
+
function getCredentialsDir() {
|
|
46
|
+
if (isLocalMode()) {
|
|
47
|
+
return path.join(process.cwd(), LOCAL_CREDS_DIR);
|
|
48
|
+
}
|
|
49
|
+
return path.join(getGlobalConfigDir(), "credentials");
|
|
50
|
+
}
|
|
51
|
+
//
|
|
52
|
+
/**
|
|
53
|
+
* Returns the full path to a configuration file.
|
|
54
|
+
* @param {string} configName - Configuration name.
|
|
55
|
+
* @returns {string} Absolute path to configuration file.
|
|
56
|
+
*/
|
|
57
|
+
function getConfigPath(configName) {
|
|
58
|
+
return path.join(getConfigurationsDir(), `gcp-options-${configName}.json`);
|
|
59
|
+
}
|
|
60
|
+
//
|
|
61
|
+
/**
|
|
62
|
+
* Returns the full path to a credentials file.
|
|
63
|
+
* @param {string} credentialsFile - Credentials file name.
|
|
64
|
+
* @returns {string} Absolute path to credentials file.
|
|
65
|
+
*/
|
|
66
|
+
function getCredentialsPath(credentialsFile) {
|
|
67
|
+
return path.join(getCredentialsDir(), credentialsFile);
|
|
68
|
+
}
|
|
69
|
+
//
|
|
70
|
+
module.exports = {
|
|
71
|
+
isLocalMode,
|
|
72
|
+
getGlobalConfigDir,
|
|
73
|
+
getConfigurationsDir,
|
|
74
|
+
getCredentialsDir,
|
|
75
|
+
getConfigPath,
|
|
76
|
+
getCredentialsPath
|
|
77
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
const ListPrompt = require("inquirer/lib/prompts/list");
|
|
2
|
+
const observe = require("inquirer/lib/utils/events");
|
|
3
|
+
const { takeUntil, filter } = require("rxjs/operators");
|
|
4
|
+
//
|
|
5
|
+
/**
|
|
6
|
+
* Custom list prompt with ESC and left arrow support.
|
|
7
|
+
* Returns null when ESC is pressed (exit completely).
|
|
8
|
+
* Returns { action: "back" } when left arrow is pressed (go back one level).
|
|
9
|
+
*/
|
|
10
|
+
class ListWithEscapePrompt extends ListPrompt {
|
|
11
|
+
_run(cb) {
|
|
12
|
+
this.done = cb;
|
|
13
|
+
const events = observe(this.rl);
|
|
14
|
+
const enableBack = this.opt.enableBack !== false;
|
|
15
|
+
//
|
|
16
|
+
// Handle ESC key - exit completely (always enabled).
|
|
17
|
+
events.keypress
|
|
18
|
+
.pipe(
|
|
19
|
+
takeUntil(events.line),
|
|
20
|
+
filter(({ key }) => key && key.name === "escape")
|
|
21
|
+
)
|
|
22
|
+
.forEach(() => {
|
|
23
|
+
const height = this.screen.height || 1;
|
|
24
|
+
const moveUp = Math.max(1, height - 1);
|
|
25
|
+
process.stdout.write(`\x1b[${moveUp}A\x1b[J\x1b[G`);
|
|
26
|
+
this.screen.done();
|
|
27
|
+
this.done(null);
|
|
28
|
+
});
|
|
29
|
+
//
|
|
30
|
+
// Handle left arrow - go back one level (only if enabled).
|
|
31
|
+
if (enableBack) {
|
|
32
|
+
events.keypress
|
|
33
|
+
.pipe(
|
|
34
|
+
takeUntil(events.line),
|
|
35
|
+
filter(({ key }) => key && key.name === "left")
|
|
36
|
+
)
|
|
37
|
+
.forEach(() => {
|
|
38
|
+
const height = this.screen.height || 1;
|
|
39
|
+
const moveUp = Math.max(1, height - 1);
|
|
40
|
+
process.stdout.write(`\x1b[${moveUp}A\x1b[J\x1b[G`);
|
|
41
|
+
this.screen.done();
|
|
42
|
+
this.done({ action: "back" });
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
//
|
|
46
|
+
return super._run(cb);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
//
|
|
50
|
+
module.exports = ListWithEscapePrompt;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const { getGlobalConfigDir } = require("./paths.js");
|
|
4
|
+
//
|
|
5
|
+
const SETTINGS_FILE = "settings.json";
|
|
6
|
+
//
|
|
7
|
+
/**
|
|
8
|
+
* Default settings.
|
|
9
|
+
*/
|
|
10
|
+
const defaultSettings = {
|
|
11
|
+
storage: {
|
|
12
|
+
maxItems: 30
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
//
|
|
16
|
+
/**
|
|
17
|
+
* Loads and returns the application settings.
|
|
18
|
+
* Looks for settings.json in local directory first, then global.
|
|
19
|
+
* @returns {object} Settings object with defaults merged.
|
|
20
|
+
*/
|
|
21
|
+
function getSettings() {
|
|
22
|
+
let userSettings = {};
|
|
23
|
+
//
|
|
24
|
+
// Try local settings first.
|
|
25
|
+
const localPath = path.join(process.cwd(), SETTINGS_FILE);
|
|
26
|
+
if (fs.existsSync(localPath)) {
|
|
27
|
+
try {
|
|
28
|
+
userSettings = JSON.parse(fs.readFileSync(localPath, "utf8"));
|
|
29
|
+
} catch {
|
|
30
|
+
// Ignore parse errors.
|
|
31
|
+
}
|
|
32
|
+
} else {
|
|
33
|
+
// Try global settings.
|
|
34
|
+
const globalPath = path.join(getGlobalConfigDir(), SETTINGS_FILE);
|
|
35
|
+
if (fs.existsSync(globalPath)) {
|
|
36
|
+
try {
|
|
37
|
+
userSettings = JSON.parse(fs.readFileSync(globalPath, "utf8"));
|
|
38
|
+
} catch {
|
|
39
|
+
// Ignore parse errors.
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
//
|
|
44
|
+
// Merge with defaults.
|
|
45
|
+
return {
|
|
46
|
+
storage: {
|
|
47
|
+
...defaultSettings.storage,
|
|
48
|
+
...userSettings.storage
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
//
|
|
53
|
+
module.exports = { getSettings };
|