@heroku/skynet 2.2.0 → 2.5.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.
Files changed (34) hide show
  1. package/README.md +47 -0
  2. package/dist/commands/allowlist/add.d.ts +2 -2
  3. package/dist/commands/allowlist/remove.d.ts +1 -1
  4. package/dist/commands/categories/add.d.ts +2 -2
  5. package/dist/commands/categories/remove.d.ts +1 -1
  6. package/dist/commands/deprovision.d.ts +6 -6
  7. package/dist/commands/deprovision.js +1 -1
  8. package/dist/commands/legal-hold/add.d.ts +11 -0
  9. package/dist/commands/legal-hold/add.js +28 -0
  10. package/dist/commands/legal-hold/remove.d.ts +11 -0
  11. package/dist/commands/legal-hold/remove.js +28 -0
  12. package/dist/commands/suspend/app-owner.d.ts +7 -7
  13. package/dist/commands/suspend/app-owner.js +2 -2
  14. package/dist/commands/suspend/apps.d.ts +5 -5
  15. package/dist/commands/suspend/user/direct.d.ts +24 -0
  16. package/dist/commands/suspend/user/direct.js +286 -0
  17. package/dist/commands/suspend/user.d.ts +10 -9
  18. package/dist/commands/suspend/user.js +17 -3
  19. package/dist/commands/suspensions/upload.d.ts +9 -0
  20. package/dist/commands/suspensions/upload.js +111 -0
  21. package/dist/commands/suspensions.d.ts +1 -1
  22. package/dist/commands/suspensions.js +2 -2
  23. package/dist/commands/unsuspend/apps.d.ts +3 -3
  24. package/dist/commands/unsuspend/user.d.ts +3 -3
  25. package/dist/commands/userpass/add.d.ts +2 -2
  26. package/dist/commands/userpass/remove.d.ts +2 -2
  27. package/dist/lib/local-storage.d.ts +31 -0
  28. package/dist/lib/local-storage.js +88 -0
  29. package/dist/lib/skynet.d.ts +16 -2
  30. package/dist/lib/skynet.js +24 -2
  31. package/dist/lib/utils.d.ts +3 -0
  32. package/dist/lib/utils.js +64 -7
  33. package/oclif.manifest.json +560 -176
  34. package/package.json +17 -17
@@ -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 { processSuspensionResult, parseResponse, handleApiError } from '../../lib/utils.js';
5
+ import { processSuspensionResult, parseResponse, handleApiError, confirmLegalHold } 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';
@@ -63,6 +63,11 @@ export default class SuspendUser extends Command {
63
63
  name: 'async',
64
64
  description: 'do not wait for suspension to complete',
65
65
  required: false
66
+ }),
67
+ 'legal-hold': Flags.boolean({
68
+ name: 'legal-hold',
69
+ description: 'mark suspension with legal hold status (prevents unsuspension until cleared)',
70
+ required: false
66
71
  })
67
72
  };
68
73
  async run() {
@@ -75,6 +80,7 @@ export default class SuspendUser extends Command {
75
80
  const category = flags.category;
76
81
  const force = flags.bypass || process.env.HEROKU_FORCE === '1';
77
82
  const deprovision = flags.deprovision;
83
+ const legalHold = flags['legal-hold'] || false;
78
84
  if (flags.notify && flags.no_notify) {
79
85
  ux.error('Flag --notify and --no-notify cannot be used together');
80
86
  }
@@ -90,10 +96,18 @@ export default class SuspendUser extends Command {
90
96
  if ((user && file) || (!user && !file)) {
91
97
  ux.error('Either --user USER or --infile must be passed, but not both');
92
98
  }
99
+ // Legal hold confirmation
100
+ if (legalHold) {
101
+ const confirmed = await confirmLegalHold();
102
+ if (!confirmed) {
103
+ this.log(color.yellow('Legal hold suspension cancelled'));
104
+ return;
105
+ }
106
+ }
93
107
  if (file) {
94
108
  ux.action.start(color.blue.bold('Starting bulk suspend...'));
95
109
  try {
96
- let response = await skynet.bulkSuspendUsers(file, notes, category, notify, force, deprovision);
110
+ let response = await skynet.bulkSuspendUsers(file, notes, category, notify, force, deprovision, legalHold);
97
111
  response = parseResponse(response);
98
112
  ux.action.stop();
99
113
  // For bulk operations, use a custom message if no message is in response
@@ -110,7 +124,7 @@ export default class SuspendUser extends Command {
110
124
  else {
111
125
  ux.action.start(color.blue.bold(`Suspending user ${color.cyan(user)}...`));
112
126
  try {
113
- let response = await skynet.suspendUser(user, notes, category, notify, force, deprovision, flags.async);
127
+ let response = await skynet.suspendUser(user, notes, category, notify, force, deprovision, flags.async, legalHold);
114
128
  response = parseResponse(response);
115
129
  ux.action.stop();
116
130
  processSuspensionResult(user, response);
@@ -0,0 +1,9 @@
1
+ import { Command } from '@heroku-cli/command';
2
+ export default class SuspensionsUpload extends Command {
3
+ static id: string;
4
+ static aliases: string[];
5
+ static description: string;
6
+ static hidden: boolean;
7
+ static examples: string[];
8
+ run(): Promise<void>;
9
+ }
@@ -0,0 +1,111 @@
1
+ import SkynetAPI from '../../lib/skynet.js';
2
+ import { requireSudo } from '../../lib/sudo.js';
3
+ import { Command } from '@heroku-cli/command';
4
+ import { color } from '@heroku-cli/color';
5
+ import { ux } from '@oclif/core';
6
+ import { confirm } from '../../lib/utils.js';
7
+ import { getPendingSuspensionRecords, deleteSuspensionRecord, getPendingSuspensionCount } from '../../lib/local-storage.js';
8
+ export default class SuspensionsUpload extends Command {
9
+ static id = 'skynet:suspensions:upload';
10
+ static aliases = ['skynet:suspensions:upload'];
11
+ static description = '(requires sudo) uploads locally stored suspension records to Skynet';
12
+ static hidden = true;
13
+ static examples = [
14
+ '$ heroku sudo skynet:suspensions:upload'
15
+ ];
16
+ async run() {
17
+ requireSudo();
18
+ await this.parse(SuspensionsUpload);
19
+ // Get count first
20
+ const count = await getPendingSuspensionCount();
21
+ if (count === 0) {
22
+ this.log(color.yellow('No pending suspension records found to upload'));
23
+ return;
24
+ }
25
+ this.log(color.blue(`Found ${color.bold(String(count))} pending suspension record(s)`));
26
+ // Confirm before uploading
27
+ const confirmed = await confirm(`Upload ${count} suspension record(s) to Skynet? (y/n)`);
28
+ if (!confirmed) {
29
+ this.log(color.yellow('Upload cancelled'));
30
+ return;
31
+ }
32
+ // Get all pending records
33
+ const records = await getPendingSuspensionRecords();
34
+ if (records.length === 0) {
35
+ this.log(color.yellow('No pending suspension records found to upload'));
36
+ return;
37
+ }
38
+ const skynet = new SkynetAPI(this.heroku.auth);
39
+ ux.action.start(color.blue.bold(`Uploading ${records.length} suspension record(s) to Skynet...`));
40
+ try {
41
+ // Upload records to Skynet
42
+ const response = await skynet.uploadSuspensionRecords(records.map(r => r.record));
43
+ ux.action.stop();
44
+ // Parse response
45
+ let parsedResponse = response;
46
+ if (typeof response === 'string') {
47
+ try {
48
+ parsedResponse = JSON.parse(response);
49
+ }
50
+ catch {
51
+ // Keep as string
52
+ }
53
+ }
54
+ // Check if upload was successful
55
+ const success = parsedResponse?.status === 'OK' ||
56
+ parsedResponse?.statusCode === 200 ||
57
+ parsedResponse?.status_code === 200;
58
+ if (success) {
59
+ this.log(color.green(`✓ Successfully uploaded ${records.length} suspension record(s) to Skynet`));
60
+ // Show details if available
61
+ if (parsedResponse.uploaded) {
62
+ this.log(color.dim(` Uploaded: ${parsedResponse.uploaded}`));
63
+ }
64
+ if (parsedResponse.failed && parsedResponse.failed > 0) {
65
+ this.log(color.yellow(` Failed: ${parsedResponse.failed}`));
66
+ }
67
+ if (parsedResponse.message) {
68
+ this.log(color.dim(` ${parsedResponse.message}`));
69
+ }
70
+ // Delete local files
71
+ ux.action.start(color.blue('Cleaning up local files...'));
72
+ let deletedCount = 0;
73
+ for (const { filepath } of records) {
74
+ try {
75
+ await deleteSuspensionRecord(filepath);
76
+ deletedCount++;
77
+ }
78
+ catch (error) {
79
+ console.error(`Failed to delete ${filepath}:`, error);
80
+ }
81
+ }
82
+ ux.action.stop();
83
+ this.log(color.green(`✓ Deleted ${deletedCount} local file(s)`));
84
+ }
85
+ else {
86
+ ux.error(`Upload failed: ${parsedResponse?.message || parsedResponse?.error || 'Unknown error'}`);
87
+ }
88
+ }
89
+ catch (error) {
90
+ ux.action.stop();
91
+ if (error.response) {
92
+ const status = error.response.statusCode;
93
+ const body = error.response.body;
94
+ let errorMessage = 'Failed to upload suspension records';
95
+ if (typeof body === 'string') {
96
+ errorMessage += `: ${body}`;
97
+ }
98
+ else if (body && body.message) {
99
+ errorMessage += `: ${body.message}`;
100
+ }
101
+ else if (body && body.error) {
102
+ errorMessage += `: ${body.error}`;
103
+ }
104
+ ux.error(`${errorMessage} (HTTP ${status})`);
105
+ }
106
+ else {
107
+ ux.error(`Failed to upload suspension records: ${error.message}`);
108
+ }
109
+ }
110
+ }
111
+ }
@@ -5,7 +5,7 @@ export default class Suspensions extends Command {
5
5
  static description: string;
6
6
  static examples: string[];
7
7
  static flags: {
8
- account: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
8
+ account: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
9
9
  };
10
10
  run(): Promise<void>;
11
11
  }
@@ -1,6 +1,6 @@
1
1
  import SkynetAPI from '../lib/skynet.js';
2
2
  import { Command } from '@heroku-cli/command';
3
- import { Flags, ux } from '@oclif/core';
3
+ import { Flags } from '@oclif/core';
4
4
  import { parseResponse } from '../lib/utils.js';
5
5
  export default class Suspensions extends Command {
6
6
  static id = 'skynet:suspensions';
@@ -23,6 +23,6 @@ export default class Suspensions extends Command {
23
23
  const skynet = new SkynetAPI(this.heroku.auth);
24
24
  let response = await skynet.suspensions(flags.account);
25
25
  response = parseResponse(response);
26
- ux.log(response);
26
+ this.log(response);
27
27
  }
28
28
  }
@@ -5,9 +5,9 @@ export default class UnsuspendApp extends Command {
5
5
  static description: string;
6
6
  static examples: string[];
7
7
  static flags: {
8
- app: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
9
- category: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
10
- notes: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
8
+ app: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
9
+ category: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
10
+ notes: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
11
11
  };
12
12
  run(): Promise<void>;
13
13
  }
@@ -5,9 +5,9 @@ export default class UnsuspendUser extends Command {
5
5
  static description: string;
6
6
  static examples: string[];
7
7
  static flags: {
8
- user: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
9
- category: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
10
- notes: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
8
+ user: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
9
+ category: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
10
+ notes: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
11
11
  };
12
12
  run(): Promise<void>;
13
13
  }
@@ -5,8 +5,8 @@ export default class AddUserpass extends Command {
5
5
  static description: string;
6
6
  static examples: string[];
7
7
  static flags: {
8
- user: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
9
- flag: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
8
+ user: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
9
+ flag: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
10
10
  };
11
11
  run(): Promise<void>;
12
12
  }
@@ -5,8 +5,8 @@ export default class RemoveUserpass extends Command {
5
5
  static description: string;
6
6
  static examples: string[];
7
7
  static flags: {
8
- user: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
9
- flag: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
8
+ user: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
9
+ flag: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
10
10
  };
11
11
  run(): Promise<void>;
12
12
  }
@@ -0,0 +1,31 @@
1
+ export interface LocalSuspensionRecord {
2
+ resourceId: string;
3
+ isApp: boolean;
4
+ reason: string;
5
+ category: string;
6
+ action: string;
7
+ createdAt: string;
8
+ method: string;
9
+ notify?: boolean;
10
+ deprovision?: boolean;
11
+ legalHold?: boolean;
12
+ }
13
+ /**
14
+ * Save a suspension record to local storage
15
+ */
16
+ export declare function saveSuspensionRecord(record: LocalSuspensionRecord): Promise<string>;
17
+ /**
18
+ * Get all pending suspension records from local storage
19
+ */
20
+ export declare function getPendingSuspensionRecords(): Promise<Array<{
21
+ filepath: string;
22
+ record: LocalSuspensionRecord;
23
+ }>>;
24
+ /**
25
+ * Delete a suspension record file
26
+ */
27
+ export declare function deleteSuspensionRecord(filepath: string): Promise<void>;
28
+ /**
29
+ * Get count of pending suspension records
30
+ */
31
+ export declare function getPendingSuspensionCount(): Promise<number>;
@@ -0,0 +1,88 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import { promisify } from 'util';
5
+ const readdir = promisify(fs.readdir);
6
+ const readFile = promisify(fs.readFile);
7
+ const writeFile = promisify(fs.writeFile);
8
+ const unlink = promisify(fs.unlink);
9
+ const mkdir = promisify(fs.mkdir);
10
+ const STORAGE_DIR_NAME = 'heroku-skynet-suspensions';
11
+ /**
12
+ * Get the temp directory path for storing suspension records
13
+ */
14
+ function getStorageDir() {
15
+ return path.join(os.tmpdir(), STORAGE_DIR_NAME);
16
+ }
17
+ /**
18
+ * Ensure the storage directory exists
19
+ */
20
+ async function ensureStorageDir() {
21
+ const dir = getStorageDir();
22
+ try {
23
+ await mkdir(dir, { recursive: true });
24
+ }
25
+ catch (error) {
26
+ if (error.code !== 'EEXIST') {
27
+ throw error;
28
+ }
29
+ }
30
+ return dir;
31
+ }
32
+ /**
33
+ * Generate a unique filename for a suspension record
34
+ */
35
+ function generateFilename(resourceId) {
36
+ const timestamp = Date.now();
37
+ const sanitized = resourceId.replace(/[^a-zA-Z0-9]/g, '_');
38
+ return `suspension_${sanitized}_${timestamp}.json`;
39
+ }
40
+ /**
41
+ * Save a suspension record to local storage
42
+ */
43
+ export async function saveSuspensionRecord(record) {
44
+ const dir = await ensureStorageDir();
45
+ const filename = generateFilename(record.resourceId);
46
+ const filepath = path.join(dir, filename);
47
+ await writeFile(filepath, JSON.stringify(record, null, 2), 'utf8');
48
+ return filepath;
49
+ }
50
+ /**
51
+ * Get all pending suspension records from local storage
52
+ */
53
+ export async function getPendingSuspensionRecords() {
54
+ const dir = getStorageDir();
55
+ // Check if directory exists
56
+ if (!fs.existsSync(dir)) {
57
+ return [];
58
+ }
59
+ const files = await readdir(dir);
60
+ const suspensionFiles = files.filter(f => f.startsWith('suspension_') && f.endsWith('.json'));
61
+ const records = [];
62
+ for (const file of suspensionFiles) {
63
+ const filepath = path.join(dir, file);
64
+ try {
65
+ const content = await readFile(filepath, 'utf8');
66
+ const record = JSON.parse(content);
67
+ records.push({ filepath, record });
68
+ }
69
+ catch (error) {
70
+ // Skip files that can't be read or parsed
71
+ console.error(`Error reading ${filepath}:`, error);
72
+ }
73
+ }
74
+ return records;
75
+ }
76
+ /**
77
+ * Delete a suspension record file
78
+ */
79
+ export async function deleteSuspensionRecord(filepath) {
80
+ await unlink(filepath);
81
+ }
82
+ /**
83
+ * Get count of pending suspension records
84
+ */
85
+ export async function getPendingSuspensionCount() {
86
+ const records = await getPendingSuspensionRecords();
87
+ return records.length;
88
+ }
@@ -6,6 +6,17 @@ interface RequestOptions {
6
6
  json?: any;
7
7
  form?: any;
8
8
  }
9
+ interface SuspensionRecord {
10
+ resourceId: string;
11
+ isApp: boolean;
12
+ reason: string;
13
+ category: string;
14
+ action: string;
15
+ createdAt: string;
16
+ method: string;
17
+ notify?: boolean;
18
+ deprovision?: boolean;
19
+ }
9
20
  export default class SkynetAPI {
10
21
  private version;
11
22
  private token;
@@ -22,11 +33,14 @@ export default class SkynetAPI {
22
33
  suspendAppOwner(app: string, notes: string, category: string, force?: boolean, deprovision?: boolean, async?: boolean): Promise<any>;
23
34
  suspendApp(app: string, notes: string, category: string, force?: boolean, async?: boolean): Promise<any>;
24
35
  unsuspendApp(app: string, category: string, notes: string): Promise<any>;
25
- suspendUser(user: string, notes: string, category: string, notify: boolean, force?: boolean, deprovision?: boolean, async?: boolean): Promise<any>;
36
+ suspendUser(user: string, notes: string, category: string, notify: boolean, force?: boolean, deprovision?: boolean, async?: boolean, legalHold?: boolean): Promise<any>;
26
37
  unsuspendUser(user: string, category: string, notes: string): Promise<any>;
27
- bulkSuspendUsers(file: string, notes: string, category: string, notify: boolean, force?: boolean, deprovision?: boolean): Promise<any>;
38
+ bulkSuspendUsers(file: string, notes: string, category: string, notify: boolean, force?: boolean, deprovision?: boolean, legalHold?: boolean): Promise<any>;
28
39
  bulkSuspendAppOwner(apps: string, notes: string, category: string, deprovision?: boolean): Promise<any>;
29
40
  deprovision(user: string, notes: string, category: string, notify?: boolean, force?: boolean): Promise<any>;
30
41
  suspensions(account: string): Promise<any>;
42
+ uploadSuspensionRecords(records: SuspensionRecord[]): Promise<any>;
43
+ addLegalHold(user: string): Promise<any>;
44
+ removeLegalHold(user: string): Promise<any>;
31
45
  }
32
46
  export {};
@@ -140,7 +140,7 @@ export default class SkynetAPI {
140
140
  json: {}
141
141
  });
142
142
  }
143
- suspendUser(user, notes, category, notify, force = false, deprovision = false, async = false) {
143
+ suspendUser(user, notes, category, notify, force = false, deprovision = false, async = false, legalHold = false) {
144
144
  const body = {
145
145
  value: user,
146
146
  reason: notes,
@@ -150,6 +150,7 @@ export default class SkynetAPI {
150
150
  notify,
151
151
  deprovision,
152
152
  sync: !async,
153
+ legal_hold: legalHold,
153
154
  };
154
155
  return this.request('/suspend/user', {
155
156
  method: 'POST',
@@ -162,7 +163,7 @@ export default class SkynetAPI {
162
163
  json: {}
163
164
  });
164
165
  }
165
- bulkSuspendUsers(file, notes, category, notify, force = false, deprovision = false) {
166
+ bulkSuspendUsers(file, notes, category, notify, force = false, deprovision = false, legalHold = false) {
166
167
  const fileStream = fs.createReadStream(file);
167
168
  const form = new FormData();
168
169
  form.append('file', fileStream);
@@ -173,6 +174,7 @@ export default class SkynetAPI {
173
174
  form.append('force', String(force));
174
175
  form.append('notify', String(notify));
175
176
  form.append('deprovision', String(deprovision));
177
+ form.append('legal_hold', String(legalHold));
176
178
  return this.request('/bulk-suspend/user', {
177
179
  method: 'POST',
178
180
  body: form
@@ -211,5 +213,25 @@ export default class SkynetAPI {
211
213
  method: 'GET'
212
214
  });
213
215
  }
216
+ uploadSuspensionRecords(records) {
217
+ return this.request('/suspensions/upload', {
218
+ method: 'POST',
219
+ json: {
220
+ records
221
+ }
222
+ });
223
+ }
224
+ addLegalHold(user) {
225
+ return this.request(`/legal_hold/${user}`, {
226
+ method: 'POST',
227
+ json: {}
228
+ });
229
+ }
230
+ removeLegalHold(user) {
231
+ return this.request(`/legal_hold/${user}`, {
232
+ method: 'DELETE',
233
+ json: {}
234
+ });
235
+ }
214
236
  }
215
237
  ;
@@ -1,3 +1,5 @@
1
+ export declare function confirm(message: string): Promise<boolean>;
2
+ export declare function promptHidden(message: string): Promise<string>;
1
3
  export declare function readlines(file: string): Promise<string[]>;
2
4
  export declare function arrayChunks<T>(array: T[], chunkSize: number): T[][];
3
5
  /**
@@ -15,3 +17,4 @@ export declare function handleApiError(error: any, operation: string, throwError
15
17
  export declare function processSuspensionResult(suspendObject: any, response: any): void;
16
18
  export declare function processUnsuspendResult(unsuspendObject: any, response: any): void;
17
19
  export declare function logError(response: any): void;
20
+ export declare function confirmLegalHold(): Promise<boolean>;
package/dist/lib/utils.js CHANGED
@@ -1,7 +1,52 @@
1
1
  import split from 'split';
2
2
  import fs from 'fs';
3
+ import * as readline from 'readline';
3
4
  import { ux } from '@oclif/core';
4
5
  import { color } from '@heroku-cli/color';
6
+ // Helper to write to stdout consistently
7
+ function log(message) {
8
+ process.stdout.write(message + '\n');
9
+ }
10
+ // Helper for confirm prompts - exported for use in commands
11
+ export async function confirm(message) {
12
+ const rl = readline.createInterface({
13
+ input: process.stdin,
14
+ output: process.stdout
15
+ });
16
+ return new Promise((resolve) => {
17
+ rl.question(`${message} `, (answer) => {
18
+ rl.close();
19
+ resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
20
+ });
21
+ });
22
+ }
23
+ // Helper for password/hidden input prompts - exported for use in commands
24
+ export async function promptHidden(message) {
25
+ const rl = readline.createInterface({
26
+ input: process.stdin,
27
+ output: process.stdout
28
+ });
29
+ // Hide the input
30
+ const stdin = process.stdin;
31
+ const originalSetRawMode = stdin.setRawMode ? stdin.setRawMode.bind(stdin) : null;
32
+ return new Promise((resolve) => {
33
+ rl.question(`${message}: `, (answer) => {
34
+ rl.close();
35
+ console.log(); // New line after hidden input
36
+ resolve(answer);
37
+ });
38
+ // Attempt to hide input (may not work in all terminals)
39
+ if (originalSetRawMode) {
40
+ stdin.setRawMode(true);
41
+ stdin.on('data', (char) => {
42
+ const c = char.toString();
43
+ if (c === '\r' || c === '\n') {
44
+ stdin.setRawMode(false);
45
+ }
46
+ });
47
+ }
48
+ });
49
+ }
5
50
  export function readlines(file) {
6
51
  return new Promise(function (resolve, reject) {
7
52
  const entries = [];
@@ -69,9 +114,9 @@ export function handleApiError(error, operation, throwError = true) {
69
114
  }
70
115
  else if (!error?.response?.body) {
71
116
  // 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}`));
117
+ log(color.red(`Error: ${error?.message || error}`));
73
118
  }
74
- ux.log(color.bgRed(`Failed to ${operation}`));
119
+ log(color.bgRed(`Failed to ${operation}`));
75
120
  if (throwError) {
76
121
  throw error;
77
122
  }
@@ -80,15 +125,15 @@ export function processSuspensionResult(suspendObject, response) {
80
125
  if (response?.success === 'ok' || response?.status === 'OK' || response?.statusCode === 200) {
81
126
  const message = response?.message || response?.Message;
82
127
  if (message) {
83
- ux.log(color.green(message));
128
+ log(color.green(message));
84
129
  }
85
130
  else {
86
- ux.log(color.green(`Suspended ${suspendObject}`));
131
+ log(color.green(`Suspended ${suspendObject}`));
87
132
  }
88
133
  }
89
134
  else {
90
135
  logError(response);
91
- ux.log(color.bgRed(`Failed to suspend ${suspendObject}`));
136
+ log(color.bgRed(`Failed to suspend ${suspendObject}`));
92
137
  }
93
138
  }
94
139
  export function processUnsuspendResult(unsuspendObject, response) {
@@ -129,10 +174,22 @@ export function logError(response) {
129
174
  }
130
175
  // If we have parts, combine them into a single line
131
176
  if (parts.length > 0) {
132
- ux.log(color.red(parts.join(' - ')));
177
+ log(color.red(parts.join(' - ')));
133
178
  }
134
179
  else {
135
180
  // If response doesn't have expected error format, log the whole thing
136
- ux.log(color.red(`Error: ${JSON.stringify(response)}`));
181
+ log(color.red(`Error: ${JSON.stringify(response)}`));
137
182
  }
138
183
  }
184
+ export async function confirmLegalHold() {
185
+ log('');
186
+ log(color.yellow.bold('⚠ LEGAL HOLD WARNING'));
187
+ log(color.yellow('This suspension will be marked with legal hold status.'));
188
+ log(color.yellow('Users under legal hold:'));
189
+ log(color.yellow(' • Cannot be unsuspended until legal hold is cleared'));
190
+ log(color.yellow(' • Are tracked for legal/compliance purposes'));
191
+ log(color.yellow(' • May have data retention requirements'));
192
+ log('');
193
+ const confirmed = await confirm('Proceed with legal hold suspension? (y/n)');
194
+ return confirmed;
195
+ }