@fishawack/lab-env 5.7.0-beta.4 → 5.7.0-beta.6

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/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  ## Changelog
2
2
 
3
+ ### 5.7.0-beta.6 (2026-07-03)
4
+
5
+ #### Features
6
+
7
+ * **devops:** misc.json setup, SES rotation recovery, runner ordering ([3c6ec70](https://bitbucket.org/fishawackdigital/lab-env/commits/3c6ec70df5e54ad4683a9a5b4e8e3accb681121d))
8
+
9
+ #### Bug Fixes
10
+
11
+ * add timeout for aws key calls themselves ([389444c](https://bitbucket.org/fishawackdigital/lab-env/commits/389444cf7c0c61273b8dc7edf2d0d6695a1047a7))
12
+ * concurrency set back to 5 for more stable key commands ([a5e7382](https://bitbucket.org/fishawackdigital/lab-env/commits/a5e7382b9731ad2a5a60110a54e27ebe4af45391))
13
+
14
+ ### 5.7.0-beta.5 (2026-07-03)
15
+
16
+ #### Bug Fixes
17
+
18
+ * allow rekey outside of projects ([94b511b](https://bitbucket.org/fishawackdigital/lab-env/commits/94b511b629fbf80b842a9ab5db9f3671f1f603f0))
19
+ * retry on rate limits and aws errors on key commands ([0244221](https://bitbucket.org/fishawackdigital/lab-env/commits/0244221b8a263256f015380280024c862213b885))
20
+ * safe check for misc bitbucket vars ([cee035c](https://bitbucket.org/fishawackdigital/lab-env/commits/cee035c25f927d1b5f55c00f10fa35f08733e4bf))
21
+
3
22
  ### 5.7.0-beta.4 (2026-07-03)
4
23
 
5
24
  #### Bug Fixes
@@ -75,7 +75,7 @@ module.exports = [
75
75
  }));
76
76
 
77
77
  await parallelRunner(batches, {
78
- concurrency: 10,
78
+ concurrency: 5,
79
79
  verb: "Remove credentials",
80
80
  });
81
81
  },
@@ -142,6 +142,16 @@ module.exports = [
142
142
  }
143
143
  }
144
144
 
145
+ if (preset === "devops") {
146
+ while (!(await test.targets())) {
147
+ await guide.targets(argv);
148
+ }
149
+
150
+ while (!(await test.misc())) {
151
+ await guide.misc(argv);
152
+ }
153
+ }
154
+
145
155
  // Update diagnosis version in ~/.lab-env config file
146
156
  let spinner = new Spinner(
147
157
  `Writing options to ${path.join(os.homedir(), "", ".lab-env")}`,
@@ -95,18 +95,32 @@ module.exports = [
95
95
 
96
96
  if (answer.check) {
97
97
  try {
98
- const creds = await aws.ses.ensureOperatorSESCredentials(
98
+ let creds = await aws.ses.ensureOperatorSESCredentials(
99
99
  operator.username,
100
100
  );
101
101
 
102
+ if (!creds) {
103
+ answer = await inquirer.prompt([
104
+ {
105
+ type: "confirm",
106
+ name: "check",
107
+ message:
108
+ "SES IAM user exists but secret is not stored locally. Rotate credentials to recapture?",
109
+ default: "Y",
110
+ },
111
+ ]);
112
+
113
+ if (answer.check) {
114
+ creds = await aws.ses.rotateOperatorSESCredentials(
115
+ operator.username,
116
+ );
117
+ }
118
+ }
119
+
102
120
  if (creds) {
103
121
  aws.ses.updateMiscNodemailer(creds);
104
122
  console.log(
105
- "\n ✓ SES email credentials created and saved to misc.json\n",
106
- );
107
- } else {
108
- console.log(
109
- "\n ⚠ SES IAM user exists but keys were already created. Rotate with `fw rekey` to generate new credentials.\n",
123
+ "\n ✓ SES email credentials saved to misc.json\n",
110
124
  );
111
125
  }
112
126
  } catch (err) {
@@ -133,7 +147,7 @@ module.exports = [
133
147
  }));
134
148
 
135
149
  const resultsMap = await parallelRunner(batches, {
136
- concurrency: 10,
150
+ concurrency: 5,
137
151
  verb: "Create credentials",
138
152
  });
139
153
 
@@ -95,18 +95,32 @@ module.exports = [
95
95
 
96
96
  if (answer.check) {
97
97
  try {
98
- const creds = await aws.ses.ensureOperatorSESCredentials(
98
+ let creds = await aws.ses.ensureOperatorSESCredentials(
99
99
  operator.username,
100
100
  );
101
101
 
102
+ if (!creds) {
103
+ answer = await inquirer.prompt([
104
+ {
105
+ type: "confirm",
106
+ name: "check",
107
+ message:
108
+ "SES IAM user exists but secret is not stored locally. Rotate credentials to recapture?",
109
+ default: "Y",
110
+ },
111
+ ]);
112
+
113
+ if (answer.check) {
114
+ creds = await aws.ses.rotateOperatorSESCredentials(
115
+ operator.username,
116
+ );
117
+ }
118
+ }
119
+
102
120
  if (creds) {
103
121
  aws.ses.updateMiscNodemailer(creds);
104
122
  console.log(
105
- "\n \u2713 SES email credentials created and saved to misc.json\n",
106
- );
107
- } else {
108
- console.log(
109
- "\n \u26a0 SES IAM user exists but keys were already created. Rotate with `fw rekey` to generate new credentials.\n",
123
+ "\n \u2713 SES email credentials saved to misc.json\n",
110
124
  );
111
125
  }
112
126
  } catch (err) {
@@ -117,10 +131,19 @@ module.exports = [
117
131
  }
118
132
  }
119
133
 
120
- // Separate non-operator users and operator
121
- // Operator MUST be processed last per account rotating the
122
- // operator's key invalidates the credentials used for all API calls
123
- let nonOperatorUsers = users.filter((u) => u !== operator.username);
134
+ // Separate users into: regular, runners, operator
135
+ // Processing order: regular users runners operator (last)
136
+ let runners = users.filter((u) => {
137
+ let config = _.config.users.find((d) => d.username === u);
138
+ return (
139
+ u !== operator.username &&
140
+ config &&
141
+ (config.runner || u === "aws-runner")
142
+ );
143
+ });
144
+ let nonOperatorUsers = users.filter(
145
+ (u) => u !== operator.username && !runners.includes(u),
146
+ );
124
147
  let includeOperator = users.includes(operator.username);
125
148
 
126
149
  let credentials = {};
@@ -144,7 +167,7 @@ module.exports = [
144
167
  }));
145
168
 
146
169
  const resultsMap = await parallelRunner(batches, {
147
- concurrency: 10,
170
+ concurrency: 5,
148
171
  verb: "Rotate credentials",
149
172
  });
150
173
 
@@ -164,6 +187,45 @@ module.exports = [
164
187
  }
165
188
  }
166
189
 
190
+ // Process runner users as a group (after regular, before operator)
191
+ if (runners.length) {
192
+ const runnerBatches = clients.map((client) => ({
193
+ users: runners,
194
+ client,
195
+ fn: async (user) => {
196
+ setAWSClientDefaults(client);
197
+
198
+ await aws.iam.removeIAMUser(`fw-automation-${user}`);
199
+
200
+ return aws.iam.createFWIAMUser(
201
+ `fw-automation-${user}`,
202
+ _.config.users.find((d) => d.username === user)
203
+ .permissions,
204
+ );
205
+ },
206
+ }));
207
+
208
+ const runnerResults = await parallelRunner(runnerBatches, {
209
+ concurrency: 5,
210
+ verb: "Rotate runner credentials",
211
+ });
212
+
213
+ for (const [user, entries] of runnerResults) {
214
+ credentials[user] = {};
215
+
216
+ for (const { client, res } of entries) {
217
+ credentials[user][client] = {
218
+ key:
219
+ (res.AccessKey && res.AccessKey.AccessKeyId) ||
220
+ res.AccessKeyMetadata[0].AccessKeyId,
221
+ secret:
222
+ (res.AccessKey && res.AccessKey.SecretAccessKey) ||
223
+ "** secret **",
224
+ };
225
+ }
226
+ }
227
+ }
228
+
167
229
  // Process operator sequentially — must be last per account
168
230
  if (includeOperator) {
169
231
  const operatorBatches = clients.map((client) => ({
@@ -3,6 +3,34 @@ const chalk = require("chalk");
3
3
  const { Spinner } = require("./utilities");
4
4
 
5
5
  const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
6
+ const MAX_RETRIES = 15;
7
+ const INITIAL_DELAY = 2000;
8
+ const MAX_DELAY = 60000;
9
+ const TASK_TIMEOUT = 120000; // 2 min hard timeout per attempt — prevents hung TCP connections
10
+
11
+ const RETRYABLE_ERRORS = [
12
+ "Throttling",
13
+ "ThrottlingException",
14
+ "TooManyRequestsException",
15
+ "RequestLimitExceeded",
16
+ "ServiceUnavailable",
17
+ "InternalFailure",
18
+ "RequestTimeout",
19
+ "RequestTimeoutException",
20
+ ];
21
+
22
+ function isRetryable(err) {
23
+ if (
24
+ RETRYABLE_ERRORS.includes(err.name) ||
25
+ RETRYABLE_ERRORS.includes(err.Code)
26
+ ) {
27
+ return true;
28
+ }
29
+
30
+ const statusCode = err.$metadata?.httpStatusCode;
31
+
32
+ return statusCode === 429 || statusCode === 503 || statusCode === 500;
33
+ }
6
34
 
7
35
  function formatElapsed(ms) {
8
36
  const secs = Math.floor(ms / 1000);
@@ -78,7 +106,20 @@ module.exports = async function parallelRunner(
78
106
  continue;
79
107
  }
80
108
 
81
- if (state.status === "active") {
109
+ if (state.status === "retrying") {
110
+ const remaining = Math.max(
111
+ 0,
112
+ Math.ceil(
113
+ (state.retryDelay - (Date.now() - state.retryStart)) /
114
+ 1000,
115
+ ),
116
+ );
117
+ lines.push(
118
+ chalk.yellow(
119
+ ` ↻ ${verb}: ${user} - ${state.client} (${state.batchesDone + 1}/${totalBatches}) retry ${state.retryAttempt}/${MAX_RETRIES} ${remaining}s`,
120
+ ),
121
+ );
122
+ } else if (state.status === "active") {
82
123
  const taskElapsed = Date.now() - state.taskStart;
83
124
  let suffix = "";
84
125
  if (taskElapsed > 5000) {
@@ -130,24 +171,75 @@ module.exports = async function parallelRunner(
130
171
  const groupUsers = group.filter((u) => batch.users.includes(u));
131
172
 
132
173
  await Promise.all(
133
- groupUsers.map(async (user) => {
174
+ groupUsers.map(async (user, userIndex) => {
134
175
  const state = groupStates.get(user);
135
176
  state.client = batch.client;
136
- state.status = "active";
137
177
  state.taskStart = Date.now();
138
178
 
139
- try {
140
- const res = await batch.fn(user);
141
- results.get(user).push({ client: batch.client, res });
142
- state.status = "done";
143
- } catch (err) {
144
- state.status = "error";
145
- state.hasError = true;
146
- state.errorMsg = err.message || String(err);
147
- errors.push({ user, client: batch.client, error: err });
148
- } finally {
149
- state.batchesDone++;
179
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
180
+ state.status = "active";
181
+ state.taskStart = Date.now();
182
+
183
+ try {
184
+ let timeoutHandle;
185
+ const res = await Promise.race([
186
+ batch.fn(user),
187
+ new Promise((_, reject) => {
188
+ timeoutHandle = setTimeout(
189
+ () =>
190
+ reject(
191
+ Object.assign(
192
+ new Error(
193
+ "Request timed out after 2 minutes — will retry",
194
+ ),
195
+ { name: "RequestTimeout" },
196
+ ),
197
+ ),
198
+ TASK_TIMEOUT,
199
+ );
200
+ }),
201
+ ]).finally(() => clearTimeout(timeoutHandle));
202
+ results
203
+ .get(user)
204
+ .push({ client: batch.client, res });
205
+ state.status = "done";
206
+ state.retryAttempt = 0;
207
+ break;
208
+ } catch (err) {
209
+ if (!isRetryable(err) || attempt === MAX_RETRIES) {
210
+ state.status = "error";
211
+ state.hasError = true;
212
+ state.errorMsg = err.message || String(err);
213
+ errors.push({
214
+ user,
215
+ client: batch.client,
216
+ error: err,
217
+ });
218
+ break;
219
+ }
220
+
221
+ let delay = Math.min(
222
+ INITIAL_DELAY * Math.pow(2, attempt),
223
+ MAX_DELAY,
224
+ );
225
+ // Stagger retries per user to avoid thundering herd
226
+ delay +=
227
+ Math.floor(Math.random() * delay * 0.3) +
228
+ userIndex * 1000;
229
+
230
+ state.status = "retrying";
231
+ state.retryAttempt = attempt + 1;
232
+ state.retryDelay = delay;
233
+ state.retryStart = Date.now();
234
+ throttleCount++;
235
+
236
+ await new Promise((resolve) =>
237
+ setTimeout(resolve, delay),
238
+ );
239
+ }
150
240
  }
241
+
242
+ state.batchesDone++;
151
243
  }),
152
244
  );
153
245
  }
@@ -68,17 +68,17 @@ const bitbucketApi = (module.exports.bitbucketApi =
68
68
  "https://api.bitbucket.org/2.0/repositories");
69
69
 
70
70
  module.exports.apis = {
71
- bbWorkspaceAPI: `${bitbucketApi}/${misc.bitbucket.workspace}`,
71
+ bbWorkspaceAPI: `${bitbucketApi}/${misc?.bitbucket?.workspace}`,
72
72
  };
73
73
 
74
74
  // URLs
75
75
  module.exports.urls = {
76
- bitbucketSSH: `git@bitbucket.org:${misc.bitbucket.workspace}`,
76
+ bitbucketSSH: `git@bitbucket.org:${misc?.bitbucket?.workspace}`,
77
77
  };
78
78
 
79
79
  // Request Headers
80
80
  module.exports.headers = {
81
- bbHeaders: encode(misc.bitbucket.username, misc.bitbucket.token),
81
+ bbHeaders: encode(misc?.bitbucket?.username, misc?.bitbucket?.token),
82
82
  };
83
83
 
84
84
  // Required global node modules
@@ -176,7 +176,15 @@ const MISC_PATH = `${os.homedir()}/targets/misc.json`;
176
176
  module.exports.updateMiscNodemailer = (creds) => {
177
177
  const { misc } = require("../../libs/vars");
178
178
 
179
- const file = JSON.parse(readFileSync(MISC_PATH, { encoding: "utf8" }));
179
+ let file = {};
180
+
181
+ try {
182
+ file = JSON.parse(readFileSync(MISC_PATH, { encoding: "utf8" }));
183
+ } catch {
184
+ const { mkdirSync } = require("fs");
185
+ mkdirSync(require("path").dirname(MISC_PATH), { recursive: true });
186
+ file = { bitbucket: {}, gitlab: {} };
187
+ }
180
188
 
181
189
  file.nodemailer = {
182
190
  driver: "AWSSES",
package/globals.js CHANGED
@@ -279,6 +279,7 @@ if (composer && composer.require && composer.require["laravel/framework"]) {
279
279
  args[0] !== "new" &&
280
280
  args[0] !== "key" &&
281
281
  args[0] !== "dekey" &&
282
+ args[0] !== "rekey" &&
282
283
  args[0] !== "lint" &&
283
284
  args[0] !== "workspace" &&
284
285
  args[0] !== "prune"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fishawack/lab-env",
3
- "version": "5.7.0-beta.4",
3
+ "version": "5.7.0-beta.6",
4
4
  "description": "Docker manager for FW",
5
5
  "main": "cli.js",
6
6
  "scripts": {