@agilecustoms/envctl 1.14.0 → 1.15.1

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.
@@ -14,15 +14,15 @@ export const NO_TIMEOUT = 0;
14
14
  export class Cli {
15
15
  constructor() {
16
16
  }
17
- async run(command, args, timeoutMs = NO_TIMEOUT, out_scanner, in_scanner) {
18
- const needsInteractive = !!in_scanner;
19
- const spawnOptions = {
20
- stdio: [needsInteractive ? 'pipe' : 'ignore', 'pipe', 'pipe'],
21
- };
17
+ async run(command, args, options = {}) {
18
+ const timeoutMs = options.timeoutMs ?? NO_TIMEOUT;
19
+ const interactive = options.interactive ?? !!options.inScanner;
20
+ const stdio = [interactive ? 'pipe' : 'ignore', 'pipe', 'pipe'];
21
+ const spawnOptions = { stdio };
22
22
  const child = spawn(command, args, spawnOptions);
23
23
  child.stdout.setEncoding('utf8');
24
24
  child.stderr.setEncoding('utf8');
25
- const rl = needsInteractive
25
+ const rl = interactive
26
26
  ? readline.createInterface({
27
27
  input: process.stdin,
28
28
  output: process.stdout,
@@ -53,13 +53,13 @@ export class Cli {
53
53
  on('SIGTERM');
54
54
  on('SIGHUP');
55
55
  rl?.on('line', (line) => {
56
- in_scanner?.(line);
56
+ options.inScanner?.(line);
57
57
  if (child.stdin.writable) {
58
58
  child.stdin.write(line + '\n');
59
59
  }
60
60
  });
61
61
  function processLine(line) {
62
- out_scanner?.(line);
62
+ options.outScanner?.(line);
63
63
  console.log(line);
64
64
  }
65
65
  let buffer = '';
@@ -2,7 +2,7 @@ import { BusinessException, KnownException, NotFoundException } from '../excepti
2
2
  import { logger } from '../logger.js';
3
3
  import { EnvStatus } from '../model/index.js';
4
4
  import { toLocalTime } from '../util.js';
5
- import { HttpClient, toUrl } from './HttpClient.js';
5
+ import { HttpClient } from './HttpClient.js';
6
6
  export class EnvApiClient {
7
7
  httpClient;
8
8
  constructor(httpClient) {
@@ -33,7 +33,7 @@ export class EnvApiClient {
33
33
  }
34
34
  throw error;
35
35
  }
36
- return toUrl(`/public/env/${env.key}/extend?token=${result.token}`);
36
+ return this.httpClient.getUrl(`/public/env/${env.key}/extend?token=${result.token}`);
37
37
  }
38
38
  async create(env) {
39
39
  let result;
@@ -1,10 +1,7 @@
1
- import { BusinessException, HttpException, NotFoundException } from '../exceptions.js';
1
+ import { BusinessException, HttpException, KnownException, NotFoundException } from '../exceptions.js';
2
2
  import { logger } from '../logger.js';
3
+ import { DEFAULT_HOST } from '../service/ConfigService.js';
3
4
  import { ConfigService } from '../service/index.js';
4
- const HOST = 'cli.maintenance.agilecustoms.com';
5
- export function toUrl(path) {
6
- return `https://${HOST}/env-api${path}`;
7
- }
8
5
  export class HttpClient {
9
6
  version;
10
7
  commandId;
@@ -26,8 +23,12 @@ export class HttpClient {
26
23
  }
27
24
  return await this.fetch(path, options);
28
25
  }
26
+ getUrl(path) {
27
+ const host = this.configService.getHost();
28
+ return `https://${host}/env-api${path}`;
29
+ }
29
30
  async fetch(path, options = {}) {
30
- const url = toUrl(path);
31
+ const url = this.getUrl(path);
31
32
  const headers = new Headers(options.headers);
32
33
  headers.set('x-command-id', this.commandId);
33
34
  headers.set('x-client-version', this.version);
@@ -44,6 +45,15 @@ export class HttpClient {
44
45
  response = await fetch(url, options);
45
46
  }
46
47
  catch (error) {
48
+ if (error.cause && error.cause.errno == -3008) {
49
+ const host = error.cause.hostname;
50
+ if (host == DEFAULT_HOST) {
51
+ throw new KnownException('Can not reach backend. Network issue?');
52
+ }
53
+ else {
54
+ throw new KnownException('Unresolvable hostname ' + error.cause.hostname);
55
+ }
56
+ }
47
57
  throw new Error('Error (network?) making the request:', { cause: error });
48
58
  }
49
59
  const contentType = response.headers?.get('Content-Type') || '';
package/dist/index.js CHANGED
@@ -15,7 +15,8 @@ const program = new Command();
15
15
  program
16
16
  .name('envctl')
17
17
  .description('CLI to manage environments')
18
- .version(pkg.version)
18
+ .version(pkg.version, '', 'Output the version number')
19
+ .helpOption('-h, --help', 'Display help for command')
19
20
  .option('--verbose', 'Verbose output (w/ debug logs)');
20
21
  configure(program, configService);
21
22
  createEphemeral(program, configService, envService);
@@ -3,6 +3,7 @@ import * as os from 'node:os';
3
3
  import path from 'path';
4
4
  import { KnownException } from '../exceptions.js';
5
5
  const CONFIG_DIR = path.join(os.homedir(), '.envctl');
6
+ export const DEFAULT_HOST = 'cli.maintenance.agilecustoms.com';
6
7
  var EnvKey;
7
8
  (function (EnvKey) {
8
9
  EnvKey["API_KEY"] = "ENVCTL_API_KEY";
@@ -18,21 +19,23 @@ export class ConfigService {
18
19
  config;
19
20
  constructor() {
20
21
  }
21
- loadConfig(profile) {
22
+ loadConfig(profile, failOnMissingCustomProfile = true) {
22
23
  if (this.config)
23
24
  throw new Error('load config second time?');
24
- if (!profile) {
25
- profile = env(EnvKey.PROFILE) || 'default';
26
- }
25
+ const customProfile = profile || env(EnvKey.PROFILE);
26
+ profile = customProfile || 'default';
27
27
  const configPath = path.join(CONFIG_DIR, `${profile}.json`);
28
28
  if (fs.existsSync(configPath)) {
29
29
  const data = fs.readFileSync(configPath, 'utf-8');
30
30
  this.config = JSON.parse(data);
31
31
  }
32
+ else if (customProfile && failOnMissingCustomProfile) {
33
+ throw new KnownException(`Profile ${customProfile} doesn't exist`);
34
+ }
32
35
  return this.config;
33
36
  }
34
37
  saveConfig(profile, config) {
35
- const mergedConfig = { ...this.loadConfig(profile), ...config };
38
+ const mergedConfig = { ...this.loadConfig(profile, false), ...config };
36
39
  const data = JSON.stringify(mergedConfig, null, 2);
37
40
  fs.mkdirSync(CONFIG_DIR, { recursive: true });
38
41
  const configPath = path.join(CONFIG_DIR, `${profile}.json`);
@@ -61,4 +64,7 @@ export class ConfigService {
61
64
  getVarEphemeral() {
62
65
  return this.config?.varEphemeral;
63
66
  }
67
+ getHost() {
68
+ return this.config?.host || DEFAULT_HOST;
69
+ }
64
70
  }
@@ -2,7 +2,7 @@ import fs from 'fs';
2
2
  import { createHash } from 'node:crypto';
3
3
  import path from 'path';
4
4
  import { confirm } from '@inquirer/prompts';
5
- import { NO_TIMEOUT } from '../client/Cli.js';
5
+ import {} from '../client/Cli.js';
6
6
  import { AbortedException, KnownException, ProcessException, TimeoutException } from '../exceptions.js';
7
7
  import { logger } from '../logger.js';
8
8
  import { LocalStateService } from './LocalStateService.js';
@@ -188,32 +188,14 @@ export class TerraformAdapter {
188
188
  });
189
189
  return result;
190
190
  }
191
- async plan(env, args, onDemandVars = {}) {
191
+ async plan(env, args) {
192
192
  args = this.tfArgs(env, args);
193
- await this._plan(args, onDemandVars, 1);
193
+ await this._plan(args, 1);
194
194
  }
195
- async _plan(args, onDemandVars, attemptNo) {
196
- let inputTfVariable = false;
197
- let tfVarName = '';
198
- function out_scanner(line) {
199
- inputTfVariable = false;
200
- const match = line.match(/var\.([a-zA-Z_0-9]+)/);
201
- if (match) {
202
- tfVarName = match[1];
203
- }
204
- else if (line.includes('Enter a value:')) {
205
- inputTfVariable = true;
206
- }
207
- }
208
- function in_scanner(line) {
209
- if (inputTfVariable && tfVarName) {
210
- onDemandVars[tfVarName] = line;
211
- tfVarName = '';
212
- }
213
- }
195
+ async _plan(args, attemptNo) {
214
196
  logger.info('Running: terraform plan ' + args.join(' ') + '\n');
215
197
  try {
216
- await this.cli.run('terraform', ['plan', ...args], NO_TIMEOUT, out_scanner, in_scanner);
198
+ await this.cli.run('terraform', ['plan', ...args], { interactive: true });
217
199
  }
218
200
  catch (error) {
219
201
  if (!(error instanceof ProcessException)) {
@@ -221,13 +203,13 @@ export class TerraformAdapter {
221
203
  }
222
204
  if (attemptNo < MAX_ATTEMPTS && RETRYABLE_ERRORS.some(err => error.message.includes(err))) {
223
205
  logger.warn(`Retrying terraform plan due to error: ${error.message}`);
224
- return this._plan(args, onDemandVars, attemptNo + 1);
206
+ return this._plan(args, attemptNo + 1);
225
207
  }
226
208
  const lockId = this.lockId(error, attemptNo);
227
209
  if (lockId) {
228
210
  await this.promptUnlock(lockId);
229
211
  logger.info('State unlocked, retrying terraform plan');
230
- return this._plan(args, onDemandVars, attemptNo + 1);
212
+ return this._plan(args, attemptNo + 1);
231
213
  }
232
214
  throw new KnownException(`terraform plan failed with code ${error.code}:\n${error.message}`, { cause: error });
233
215
  }
@@ -250,13 +232,13 @@ export class TerraformAdapter {
250
232
  this.printTime();
251
233
  const nowUtcSeconds = Math.floor(this.now() / 1000);
252
234
  const gracePeriod = 10;
253
- const timeout = (ttl - nowUtcSeconds - gracePeriod) * 1000;
254
- if (timeout <= 0) {
235
+ const timeoutMs = (ttl - nowUtcSeconds - gracePeriod) * 1000;
236
+ if (timeoutMs <= 0) {
255
237
  throw new KnownException('TTL expired before terraform apply could start');
256
238
  }
257
- logger.debug('timeout(ms): ' + timeout);
239
+ logger.debug('timeout(ms): ' + timeoutMs);
258
240
  try {
259
- await this.cli.run('terraform', ['apply', ...args], timeout);
241
+ await this.cli.run('terraform', ['apply', ...args], { timeoutMs });
260
242
  this.printTime();
261
243
  }
262
244
  catch (error) {
@@ -282,14 +264,14 @@ export class TerraformAdapter {
282
264
  }
283
265
  async _destroy(args, force, attemptNo = 1) {
284
266
  let wrongDir = false;
285
- const scanner = (line) => {
267
+ const outScanner = (line) => {
286
268
  if (line.includes('Either you have not created any objects yet or the existing objects were')) {
287
269
  wrongDir = true;
288
270
  }
289
271
  };
290
- logger.info('Running: terraform destroy -auto-approve' + args.join(' ') + '\n');
272
+ logger.info('Running: terraform destroy -auto-approve ' + args.join(' ') + '\n');
291
273
  try {
292
- await this.cli.run('terraform', ['destroy', '-auto-approve', ...args], NO_TIMEOUT, scanner);
274
+ await this.cli.run('terraform', ['destroy', '-auto-approve', ...args], { outScanner, interactive: true });
293
275
  }
294
276
  catch (error) {
295
277
  if (!(error instanceof ProcessException)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agilecustoms/envctl",
3
- "version": "1.14.0",
3
+ "version": "1.15.1",
4
4
  "description": "node.js CLI client for manage environments",
5
5
  "keywords": [
6
6
  "terraform wrapper",