@heroku/skynet 2.0.4 → 2.2.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,6 +1,7 @@
1
1
  import SkynetAPI from '../lib/skynet.js';
2
2
  import { Command } from '@heroku-cli/command';
3
3
  import { hux } from '@heroku/heroku-cli-util';
4
+ import { parseResponse } from '../lib/utils.js';
4
5
  export default class Allowlists extends Command {
5
6
  static id = 'skynet:allowlists';
6
7
  static aliases = ['skynet:allowlists'];
@@ -12,7 +13,7 @@ export default class Allowlists extends Command {
12
13
  async run() {
13
14
  const skynet = new SkynetAPI(this.heroku.auth);
14
15
  let response = await skynet.allowlists();
15
- response = JSON.parse(response);
16
+ response = parseResponse(response);
16
17
  if (Object.keys(response).length > 0) {
17
18
  hux.table(response, {
18
19
  Value: {},
@@ -1,6 +1,7 @@
1
1
  import SkynetAPI from '../../lib/skynet.js';
2
2
  import { Command } from '@heroku-cli/command';
3
3
  import { hux } from '@heroku/heroku-cli-util';
4
+ import { parseResponse } from '../../lib/utils.js';
4
5
  export default class GetCategories extends Command {
5
6
  static id = 'skynet:categories';
6
7
  static aliases = ['skynet:categories'];
@@ -12,7 +13,7 @@ export default class GetCategories extends Command {
12
13
  async run() {
13
14
  const skynet = new SkynetAPI(this.heroku.auth);
14
15
  let response = await skynet.categories();
15
- response = JSON.parse(response);
16
+ response = parseResponse(response);
16
17
  hux.table(response, {
17
18
  Category: {},
18
19
  Description: {}
@@ -3,6 +3,7 @@ import { requireSudo } from '../lib/sudo.js';
3
3
  import { Command } from '@heroku-cli/command';
4
4
  import { color } from '@heroku-cli/color';
5
5
  import { Flags, ux } from '@oclif/core';
6
+ import { parseResponse } from '../lib/utils.js';
6
7
  export default class Deprovision extends Command {
7
8
  static id = 'skynet:deprovision';
8
9
  static aliases = ['skynet:deprovision'];
@@ -76,7 +77,7 @@ export default class Deprovision extends Command {
76
77
  ux.action.start(`Deprovisioning ${color.cyan(user)}`);
77
78
  let response = await skynet.deprovision(user, notes, category, notify, force);
78
79
  ux.action.stop();
79
- response = JSON.parse(response);
80
+ response = parseResponse(response);
80
81
  ux.log(`${color.cyan(response.status)}. ${response.message}. Notification ${notificationStatus}`);
81
82
  }
82
83
  }
@@ -1,10 +1,9 @@
1
1
  import SkynetAPI from '../../lib/skynet.js';
2
- import * as utils from '../../lib/utils.js';
3
2
  import { requireSudo } from '../../lib/sudo.js';
4
3
  import { Command } from '@heroku-cli/command';
5
4
  import { color } from '@heroku-cli/color';
6
5
  import { Flags, ux } from '@oclif/core';
7
- import { processSuspensionResult } from '../../lib/utils.js';
6
+ import { processSuspensionResult, parseResponse, readlines, arrayChunks } from '../../lib/utils.js';
8
7
  const chunkSize = 5;
9
8
  export default class SuspendAppOwner extends Command {
10
9
  static id = 'skynet:suspend:app-owner';
@@ -71,8 +70,8 @@ export default class SuspendAppOwner extends Command {
71
70
  ux.error('Either --app or --infile must be passed, but not both');
72
71
  }
73
72
  if (file) {
74
- const apps = await utils.readlines(file);
75
- const chunks = utils.arrayChunks(apps, chunkSize);
73
+ const apps = await readlines(file);
74
+ const chunks = arrayChunks(apps, chunkSize);
76
75
  for (const chunk of chunks) {
77
76
  ux.log('Suspending app owners: ' + chunk.join());
78
77
  const response = await skynet.bulkSuspendAppOwner(apps.join(), notes, category, deprovision);
@@ -82,7 +81,8 @@ export default class SuspendAppOwner extends Command {
82
81
  else {
83
82
  ux.action.start(`Suspending app owner of ${color.cyan(app)}`);
84
83
  let response = await skynet.suspendAppOwner(app, notes, category, force, deprovision, flags.async);
85
- response = JSON.parse(response);
84
+ response = parseResponse(response);
85
+ ux.action.stop();
86
86
  processSuspensionResult(app, response);
87
87
  }
88
88
  }
@@ -3,7 +3,7 @@ import { requireSudo } from '../../lib/sudo.js';
3
3
  import { Command } from '@heroku-cli/command';
4
4
  import { color } from '@heroku-cli/color';
5
5
  import { Flags, ux } from '@oclif/core';
6
- import { processSuspensionResult } from '../../lib/utils.js';
6
+ import { processSuspensionResult, parseResponse } from '../../lib/utils.js';
7
7
  export default class SuspendApp extends Command {
8
8
  static id = 'skynet:suspend:app';
9
9
  static aliases = ['skynet:suspend:app'];
@@ -50,7 +50,8 @@ export default class SuspendApp extends Command {
50
50
  const skynet = new SkynetAPI(this.heroku.auth);
51
51
  ux.action.start(color.blue.bold(`Suspending the app ${color.cyan(flags.app)}...`));
52
52
  let response = await skynet.suspendApp(flags.app, flags.notes, flags.category, flags.bypass || process.env.HEROKU_FORCE === '1', flags.async);
53
- response = JSON.parse(response);
53
+ response = parseResponse(response);
54
+ ux.action.stop();
54
55
  processSuspensionResult(flags.app, response);
55
56
  }
56
57
  }
@@ -2,7 +2,7 @@ import SkynetAPI from '../../lib/skynet.js';
2
2
  import { requireSudo } from '../../lib/sudo.js';
3
3
  import { Command } from '@heroku-cli/command';
4
4
  import { Flags, ux } from '@oclif/core';
5
- import { logError, processSuspensionResult } from '../../lib/utils.js';
5
+ import { processSuspensionResult, parseResponse, handleApiError } from '../../lib/utils.js';
6
6
  import { color } from '@heroku-cli/color';
7
7
  export default class SuspendUser extends Command {
8
8
  static id = 'skynet:suspend:user';
@@ -92,21 +92,36 @@ export default class SuspendUser extends Command {
92
92
  }
93
93
  if (file) {
94
94
  ux.action.start(color.blue.bold('Starting bulk suspend...'));
95
- let response = await skynet.bulkSuspendUsers(file, notes, category, notify, force, deprovision);
96
- response = JSON.parse(response);
97
- if (response.statusCode === 200) {
98
- ux.action.stop(color.green(`Bulk suspended users from ${file}. Please check slack for update on processing.`));
95
+ try {
96
+ let response = await skynet.bulkSuspendUsers(file, notes, category, notify, force, deprovision);
97
+ response = parseResponse(response);
98
+ ux.action.stop();
99
+ // For bulk operations, use a custom message if no message is in response
100
+ if ((response?.status === 'OK' || response?.statusCode === 200) && !response?.message && !response?.Message) {
101
+ response.message = `Bulk suspended users from ${file}. Please check slack for update on processing.`;
102
+ }
103
+ processSuspensionResult(file, response);
99
104
  }
100
- else {
101
- logError(response);
102
- ux.action.stop(color.bgRed(`Failed to bulk suspend users from ${file}`));
105
+ catch (error) {
106
+ ux.action.stop();
107
+ handleApiError(error, `bulk suspend users from ${file}`);
103
108
  }
104
109
  }
105
110
  else {
106
111
  ux.action.start(color.blue.bold(`Suspending user ${color.cyan(user)}...`));
107
- let response = await skynet.suspendUser(user, notes, category, notify, force, deprovision, flags.async);
108
- response = JSON.parse(response);
109
- processSuspensionResult(user, response);
112
+ try {
113
+ let response = await skynet.suspendUser(user, notes, category, notify, force, deprovision, flags.async);
114
+ response = parseResponse(response);
115
+ ux.action.stop();
116
+ processSuspensionResult(user, response);
117
+ }
118
+ catch (error) {
119
+ // got throws errors for non-2xx status codes (like 409)
120
+ ux.action.stop();
121
+ handleApiError(error, `suspend ${user}`, false);
122
+ // Don't re-throw - we've handled the error response
123
+ return;
124
+ }
110
125
  }
111
126
  }
112
127
  }
@@ -1,6 +1,7 @@
1
1
  import SkynetAPI from '../lib/skynet.js';
2
2
  import { Command } from '@heroku-cli/command';
3
3
  import { Flags, ux } from '@oclif/core';
4
+ import { parseResponse } from '../lib/utils.js';
4
5
  export default class Suspensions extends Command {
5
6
  static id = 'skynet:suspensions';
6
7
  static aliases = ['skynet:suspensions'];
@@ -21,7 +22,7 @@ export default class Suspensions extends Command {
21
22
  const { flags } = await this.parse(Suspensions);
22
23
  const skynet = new SkynetAPI(this.heroku.auth);
23
24
  let response = await skynet.suspensions(flags.account);
24
- response = JSON.parse(response);
25
+ response = parseResponse(response);
25
26
  ux.log(response);
26
27
  }
27
28
  }
@@ -2,7 +2,7 @@ import SkynetAPI from '../../lib/skynet.js';
2
2
  import { Command } from '@heroku-cli/command';
3
3
  import { color } from '@heroku-cli/color';
4
4
  import { Flags, ux } from '@oclif/core';
5
- import { processUnsuspendResult } from '../../lib/utils.js';
5
+ import { processUnsuspendResult, parseResponse } from '../../lib/utils.js';
6
6
  export default class UnsuspendApp extends Command {
7
7
  static id = 'skynet:unsuspend:app';
8
8
  static aliases = ['skynet:unsuspend:app'];
@@ -44,7 +44,7 @@ export default class UnsuspendApp extends Command {
44
44
  }
45
45
  ux.action.start(`Unsuspending app ${color.cyan(app)}`);
46
46
  let response = await skynet.unsuspendApp(app, category, notes);
47
- response = JSON.parse(response);
47
+ response = parseResponse(response);
48
48
  processUnsuspendResult(app, response);
49
49
  }
50
50
  }
@@ -3,7 +3,7 @@ import { requireSudo } from '../../lib/sudo.js';
3
3
  import { Command } from '@heroku-cli/command';
4
4
  import { color } from '@heroku-cli/color';
5
5
  import { Flags, ux } from '@oclif/core';
6
- import { processUnsuspendResult } from '../../lib/utils.js';
6
+ import { processUnsuspendResult, parseResponse } from '../../lib/utils.js';
7
7
  export default class UnsuspendUser extends Command {
8
8
  static id = 'skynet:unsuspend:user';
9
9
  static aliases = ['skynet:unsuspend:user'];
@@ -45,7 +45,7 @@ export default class UnsuspendUser extends Command {
45
45
  }
46
46
  ux.action.start(`Unsuspending ${color.cyan(user)}`);
47
47
  let response = await skynet.unsuspendUser(user, category, notes);
48
- response = JSON.parse(response);
48
+ response = parseResponse(response);
49
49
  processUnsuspendResult(user, response);
50
50
  }
51
51
  }
@@ -1,5 +1,17 @@
1
1
  export declare function readlines(file: string): Promise<string[]>;
2
2
  export declare function arrayChunks<T>(array: T[], chunkSize: number): T[][];
3
+ /**
4
+ * Parses a response that may be a string (JSON) or already an object
5
+ */
6
+ export declare function parseResponse(response: any): any;
7
+ /**
8
+ * Extracts and normalizes error information from an API error
9
+ */
10
+ export declare function extractErrorBody(error: any): any;
11
+ /**
12
+ * Handles API errors consistently, logging them and optionally throwing
13
+ */
14
+ export declare function handleApiError(error: any, operation: string, throwError?: boolean): void;
3
15
  export declare function processSuspensionResult(suspendObject: any, response: any): void;
4
16
  export declare function processUnsuspendResult(unsuspendObject: any, response: any): void;
5
17
  export declare function logError(response: any): void;
package/dist/lib/utils.js CHANGED
@@ -32,18 +32,74 @@ export function arrayChunks(array, chunkSize) {
32
32
  }
33
33
  return chunks;
34
34
  }
35
+ /**
36
+ * Parses a response that may be a string (JSON) or already an object
37
+ */
38
+ export function parseResponse(response) {
39
+ return typeof response === 'string' ? JSON.parse(response) : response;
40
+ }
41
+ /**
42
+ * Extracts and normalizes error information from an API error
43
+ */
44
+ export function extractErrorBody(error) {
45
+ if (!error?.response?.body) {
46
+ return null;
47
+ }
48
+ try {
49
+ const errorBody = typeof error.response.body === 'string'
50
+ ? JSON.parse(error.response.body)
51
+ : error.response.body;
52
+ // Add status code from the HTTP response if not in body
53
+ if (error.response.statusCode && !errorBody.statusCode) {
54
+ errorBody.statusCode = error.response.statusCode;
55
+ }
56
+ return errorBody;
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
62
+ /**
63
+ * Handles API errors consistently, logging them and optionally throwing
64
+ */
65
+ export function handleApiError(error, operation, throwError = true) {
66
+ const errorBody = extractErrorBody(error);
67
+ if (errorBody) {
68
+ logError(errorBody);
69
+ }
70
+ else if (!error?.response?.body) {
71
+ // Only log error message if we don't have a response body (already logged by logError)
72
+ ux.log(color.red(`Error: ${error?.message || error}`));
73
+ }
74
+ ux.log(color.bgRed(`Failed to ${operation}`));
75
+ if (throwError) {
76
+ throw error;
77
+ }
78
+ }
35
79
  export function processSuspensionResult(suspendObject, response) {
36
- if (response.statusCode === 200) {
37
- ux.action.stop(color.green(`Suspended ${suspendObject}`));
80
+ if (response?.success === 'ok' || response?.status === 'OK' || response?.statusCode === 200) {
81
+ const message = response?.message || response?.Message;
82
+ if (message) {
83
+ ux.log(color.green(message));
84
+ }
85
+ else {
86
+ ux.log(color.green(`Suspended ${suspendObject}`));
87
+ }
38
88
  }
39
89
  else {
40
90
  logError(response);
41
- ux.action.stop(color.bgRed(`Failed to suspend ${suspendObject}`));
91
+ ux.log(color.bgRed(`Failed to suspend ${suspendObject}`));
42
92
  }
43
93
  }
44
94
  export function processUnsuspendResult(unsuspendObject, response) {
45
- if (response.statusCode === 200) {
46
- ux.action.stop(color.green(`Unsuspended ${unsuspendObject}`));
95
+ if (response?.success === 'ok' || response?.status === 'OK' || response?.statusCode === 200) {
96
+ const message = response?.message || response?.Message;
97
+ if (message) {
98
+ ux.action.stop(color.green(message));
99
+ }
100
+ else {
101
+ ux.action.stop(color.green(`Unsuspended ${unsuspendObject}`));
102
+ }
47
103
  }
48
104
  else {
49
105
  logError(response);
@@ -51,7 +107,32 @@ export function processUnsuspendResult(unsuspendObject, response) {
51
107
  }
52
108
  }
53
109
  export function logError(response) {
54
- ux.log(`${color.red(response.status)} \'${response.statusCode}\'.`);
55
- ux.log(color.red(response.message));
56
- ux.log(color.red(`Error: ${response.error}`));
110
+ const parts = [];
111
+ // Handle API response format: { Status: "error", StatusCode: 409, Message: "...", Error: "..." }
112
+ if (response?.statusCode || response?.StatusCode) {
113
+ parts.push(`Status: ${response.statusCode || response.StatusCode}`);
114
+ }
115
+ if (response?.Status && response.Status !== 'OK') {
116
+ parts.push(response.Status);
117
+ }
118
+ if (response?.Message) {
119
+ parts.push(response.Message);
120
+ }
121
+ else if (response?.message) {
122
+ parts.push(response.message);
123
+ }
124
+ if (response?.Error) {
125
+ parts.push(response.Error);
126
+ }
127
+ else if (response?.error) {
128
+ parts.push(response.error);
129
+ }
130
+ // If we have parts, combine them into a single line
131
+ if (parts.length > 0) {
132
+ ux.log(color.red(parts.join(' - ')));
133
+ }
134
+ else {
135
+ // If response doesn't have expected error format, log the whole thing
136
+ ux.log(color.red(`Error: ${JSON.stringify(response)}`));
137
+ }
57
138
  }
@@ -756,5 +756,5 @@
756
756
  ]
757
757
  }
758
758
  },
759
- "version": "2.0.4"
759
+ "version": "2.2.0"
760
760
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroku/skynet",
3
- "version": "2.0.4",
3
+ "version": "2.2.0",
4
4
  "description": "use Skynet from Heroku CLI",
5
5
  "type": "module",
6
6
  "repository": "heroku/heroku-skynet-cli",
@@ -20,6 +20,7 @@
20
20
  "@heroku-cli/color": "^2.0.4",
21
21
  "@heroku-cli/command": "^11.8.0",
22
22
  "@heroku/heroku-cli-util": "^10.1.2",
23
+ "@oclif/core": "^2.16.0",
23
24
  "@oclif/plugin-help": "^6.2.32",
24
25
  "form-data": "^4.0.4",
25
26
  "got": "^14.6.0",
@@ -66,6 +67,7 @@
66
67
  "build:dev": "rm -rf dist && tsc -b --sourcemap",
67
68
  "build:full": "rm -rf dist && tsc -b && npm run manifest && npm run readme",
68
69
  "manifest": "oclif manifest",
70
+ "release:prepare": "np --no-yarn",
69
71
  "readme": "echo 'README generation skipped for legacy plugin format'",
70
72
  "release": "np --no-yarn --any-branch",
71
73
  "lint": "eslint . --ext .ts --config .eslintrc.json",