@beesolve/aws-accounts 1.0.7 → 1.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.
package/dist/cli.js CHANGED
@@ -11,8 +11,13 @@ import {
11
11
  resolveAwsRegion
12
12
  } from "./awsClientConfig.js";
13
13
  import { consoleLogger } from "./logger.js";
14
- import { runGraveyardCommand } from "./commands/graveyard.js";
14
+ import {
15
+ runGraveyardCloseCommand,
16
+ runGraveyardCommand
17
+ } from "./commands/graveyard.js";
18
+ import { runProfileCommand } from "./commands/profile.js";
15
19
  import { runRegenerateCommand } from "./commands/regenerate.js";
20
+ import { runValidateCommand } from "./commands/validate.js";
16
21
  import {
17
22
  runRemoteBootstrap,
18
23
  runRemoteScan,
@@ -26,12 +31,15 @@ import {
26
31
  exitCodeForCliErrorKind,
27
32
  toUsageError
28
33
  } from "./error.js";
34
+ import { readAwsContextFromFile, readPackageVersion } from "./awsConfig.js";
29
35
  const commands = [
30
36
  "bootstrap",
31
37
  "scan",
32
38
  "init",
33
39
  "regenerate",
40
+ "validate",
34
41
  "graveyard",
42
+ "profile",
35
43
  "plan",
36
44
  "apply",
37
45
  "upgrade"
@@ -51,6 +59,9 @@ async function main() {
51
59
  "ignore-unsupported": { type: "boolean", default: false },
52
60
  "allow-destructive": { type: "boolean", default: false },
53
61
  refresh: { type: "boolean", default: false },
62
+ update: { type: "boolean", default: false },
63
+ "sso-start-url": { type: "string" },
64
+ "sso-session": { type: "string", default: "sso" },
54
65
  help: { type: "boolean", default: false }
55
66
  },
56
67
  allowPositionals: true
@@ -87,7 +98,29 @@ async function main() {
87
98
  }
88
99
  return;
89
100
  }
101
+ if (command === "validate") {
102
+ const valid = await runValidateCommand({ logger });
103
+ if (!valid) {
104
+ process.exitCode = 1;
105
+ }
106
+ return;
107
+ }
90
108
  if (command === "graveyard") {
109
+ const subcommand = args.positionals[1];
110
+ if (subcommand === "close") {
111
+ await runGraveyardCloseCommand({
112
+ logger,
113
+ cachePath: ".remote-state-cache.json",
114
+ contextPath
115
+ });
116
+ return;
117
+ }
118
+ if (subcommand != null) {
119
+ printHelp(logger);
120
+ throw toUsageError(
121
+ `Unknown graveyard subcommand: "${subcommand}". Valid subcommands: close`
122
+ );
123
+ }
91
124
  await runGraveyardCommand({
92
125
  logger,
93
126
  cachePath: ".remote-state-cache.json",
@@ -95,6 +128,24 @@ async function main() {
95
128
  });
96
129
  return;
97
130
  }
131
+ if (command === "profile") {
132
+ const ssoStartUrl = args.values["sso-start-url"] ?? process.env.AWS_SSO_START_URL;
133
+ if (ssoStartUrl == null) {
134
+ printHelp(logger);
135
+ throw toUsageError(
136
+ "--sso-start-url is required for the profile command (or set AWS_SSO_START_URL)."
137
+ );
138
+ }
139
+ await runProfileCommand({
140
+ logger,
141
+ cachePath: ".remote-state-cache.json",
142
+ contextPath,
143
+ ssoStartUrl,
144
+ ssoSession: args.values["sso-session"] ?? "sso",
145
+ isTty: process.stdin.isTTY
146
+ });
147
+ return;
148
+ }
98
149
  const overwriteConfirmation = buildOverwriteConfirmation({
99
150
  yes: args.values.yes ?? false,
100
151
  isTty: process.stdin.isTTY
@@ -107,7 +158,8 @@ async function main() {
107
158
  yes: args.values.yes ?? false,
108
159
  refresh: args.values.refresh ?? false,
109
160
  allowDestructive: args.values["allow-destructive"] ?? false,
110
- ignoreUnsupported: args.values["ignore-unsupported"] ?? false
161
+ ignoreUnsupported: args.values["ignore-unsupported"] ?? false,
162
+ update: args.values.update ?? false
111
163
  },
112
164
  logger,
113
165
  overwriteConfirmation,
@@ -118,25 +170,23 @@ async function main() {
118
170
  ssoAdminClient: new SSOAdminClient(clientConfig)
119
171
  };
120
172
  if (command === "bootstrap") {
121
- return runRemoteBootstrap(remoteInput);
122
- }
123
- if (command === "scan") {
124
- return runRemoteScan(remoteInput);
125
- }
126
- if (command === "init") {
127
- return runRemoteInit(remoteInput);
128
- }
129
- if (command === "plan") {
130
- return runRemotePlan(remoteInput);
131
- }
132
- if (command === "apply") {
133
- return runRemoteApply(remoteInput);
134
- }
135
- if (command === "upgrade") {
136
- return runRemoteUpgrade(remoteInput);
173
+ await runRemoteBootstrap(remoteInput);
174
+ } else if (command === "scan") {
175
+ await runRemoteScan(remoteInput);
176
+ } else if (command === "init") {
177
+ await runRemoteInit(remoteInput);
178
+ } else if (command === "plan") {
179
+ await runRemotePlan(remoteInput);
180
+ } else if (command === "apply") {
181
+ await runRemoteApply(remoteInput);
182
+ } else if (command === "upgrade") {
183
+ await runRemoteUpgrade(remoteInput);
184
+ } else {
185
+ printHelp(logger);
186
+ process.exitCode = 1;
187
+ return;
137
188
  }
138
- printHelp(logger);
139
- process.exitCode = 1;
189
+ await printVersionBannerIfNeeded(logger);
140
190
  }
141
191
  function printHelp(logger) {
142
192
  logger.log("@beesolve/aws-accounts");
@@ -145,23 +195,27 @@ function printHelp(logger) {
145
195
  logger.log(
146
196
  " npm run cli -- bootstrap [--profile <name>] [--region <region>] [--yes]"
147
197
  );
198
+ logger.log(" npm run cli -- scan [--profile <name>] [--region <region>]");
148
199
  logger.log(
149
- " npm run cli -- scan [--profile <name>] [--region <region>]"
200
+ " npm run cli -- init [--profile <name>] [--region <region>] [--yes]"
150
201
  );
151
202
  logger.log(
152
- " npm run cli -- init [--profile <name>] [--region <region>] [--yes]"
203
+ " npm run cli -- init --update [--profile <name>] [--region <region>] [--yes]"
153
204
  );
154
205
  logger.log(" npm run cli -- regenerate [--yes]");
206
+ logger.log(" npm run cli -- validate");
155
207
  logger.log(" npm run cli -- graveyard");
208
+ logger.log(" npm run cli -- graveyard close");
156
209
  logger.log(
157
- " npm run cli -- plan [--profile <name>] [--region <region>] [--refresh]"
210
+ " npm run cli -- profile --sso-start-url <url> [--sso-session <name>] (env: AWS_SSO_START_URL)"
158
211
  );
159
212
  logger.log(
160
- " npm run cli -- apply [--profile <name>] [--region <region>] [--yes] [--allow-destructive] [--ignore-unsupported]"
213
+ " npm run cli -- plan [--profile <name>] [--region <region>] [--refresh]"
161
214
  );
162
215
  logger.log(
163
- " npm run cli -- upgrade [--profile <name>] [--region <region>]"
216
+ " npm run cli -- apply [--profile <name>] [--region <region>] [--yes] [--allow-destructive] [--ignore-unsupported]"
164
217
  );
218
+ logger.log(" npm run cli -- upgrade [--profile <name>] [--region <region>]");
165
219
  logger.log("");
166
220
  logger.log("Environment fallback:");
167
221
  logger.log(" AWS_PROFILE, AWS_REGION, AWS_DEFAULT_REGION");
@@ -194,6 +248,22 @@ function buildOverwriteConfirmation(props) {
194
248
  }
195
249
  };
196
250
  }
251
+ async function printVersionBannerIfNeeded(logger) {
252
+ try {
253
+ const [context, currentVersion] = await Promise.all([
254
+ readAwsContextFromFile(contextPath),
255
+ readPackageVersion()
256
+ ]);
257
+ const remoteVersion = context.deployment?.cliVersion;
258
+ if (remoteVersion != null && remoteVersion !== currentVersion) {
259
+ logger.log("");
260
+ logger.log(
261
+ `New version installed (local: ${currentVersion}, remote: ${remoteVersion}). Run upgrade then init --update to sync.`
262
+ );
263
+ }
264
+ } catch {
265
+ }
266
+ }
197
267
  main().catch((error) => {
198
268
  const classified = classifyCliError(error);
199
269
  consoleLogger.error(`CLI ${classified.kind} error: ${classified.message}`);
@@ -1,5 +1,31 @@
1
1
  import { readAwsContextFromFile } from "../awsConfig.js";
2
2
  import { readStateCache } from "../remoteStateCache.js";
3
+ async function runGraveyardCloseCommand(props) {
4
+ const [cache, context] = await Promise.all([
5
+ readStateCache(props.cachePath),
6
+ readAwsContextFromFile(props.contextPath)
7
+ ]);
8
+ if (cache == null) {
9
+ throw new Error(
10
+ `No remote state cache found at "${props.cachePath}". Run a scan or apply command first to populate the cache.`
11
+ );
12
+ }
13
+ const graveyardOuId = context.organization.graveyardOuId;
14
+ const eligible = cache.state.organization.accounts.filter((a) => a.parentId === graveyardOuId && a.status === "ACTIVE").sort((a, b) => a.name.localeCompare(b.name));
15
+ if (eligible.length === 0) {
16
+ props.logger.log("No accounts eligible for closure in Graveyard.");
17
+ return;
18
+ }
19
+ props.logger.log(`${eligible.length} account(s) eligible for closure:
20
+ `);
21
+ for (const account of eligible) {
22
+ props.logger.log(`# ${account.name} (${account.id})`);
23
+ props.logger.log(
24
+ `aws organizations close-account --account-id ${account.id}`
25
+ );
26
+ props.logger.log("");
27
+ }
28
+ }
3
29
  async function runGraveyardCommand(props) {
4
30
  const [cache, context] = await Promise.all([
5
31
  readStateCache(props.cachePath),
@@ -42,5 +68,6 @@ async function runGraveyardCommand(props) {
42
68
  };
43
69
  }
44
70
  export {
71
+ runGraveyardCloseCommand,
45
72
  runGraveyardCommand
46
73
  };
@@ -0,0 +1,116 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import { readAwsContextFromFile } from "../awsConfig.js";
3
+ import { readStateCache } from "../remoteStateCache.js";
4
+ async function runProfileCommand(input) {
5
+ const cache = await readStateCache(input.cachePath);
6
+ if (cache == null) {
7
+ throw new Error(
8
+ `No remote state cache found at "${input.cachePath}". Run scan or plan first.`
9
+ );
10
+ }
11
+ const context = await readAwsContextFromFile(input.contextPath);
12
+ const region = context.deployment?.region || process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || "us-east-1";
13
+ const entries = buildProfileEntries(cache.state);
14
+ if (entries.length === 0) {
15
+ input.logger.log("No account assignments found in state cache.");
16
+ return;
17
+ }
18
+ const selected = await selectEntry({ entries, logger: input.logger, isTty: input.isTty });
19
+ if (selected == null) {
20
+ return;
21
+ }
22
+ const profileName = buildProfileName(selected);
23
+ const block = renderProfileBlock({
24
+ profileName,
25
+ ssoSession: input.ssoSession,
26
+ accountId: selected.accountId,
27
+ roleName: selected.permissionSetName,
28
+ ssoStartUrl: input.ssoStartUrl,
29
+ region,
30
+ ssoRegistrationScopes: "sso:account:access"
31
+ });
32
+ input.logger.log("");
33
+ input.logger.log(block);
34
+ }
35
+ async function selectEntry(props) {
36
+ if (props.isTty !== true) {
37
+ throw new Error(
38
+ "Profile command requires an interactive terminal. Use --account and --permission-set in non-interactive mode (not yet supported)."
39
+ );
40
+ }
41
+ props.logger.log("Select an account/permission-set combination:");
42
+ props.logger.log("");
43
+ for (const [index, entry] of props.entries.entries()) {
44
+ props.logger.log(
45
+ ` ${index + 1}. ${entry.accountName} / ${entry.permissionSetName} (${entry.accountId})`
46
+ );
47
+ }
48
+ props.logger.log("");
49
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
50
+ try {
51
+ let choice;
52
+ while (choice == null) {
53
+ const answer = await rl.question(`Enter number (1-${props.entries.length}): `);
54
+ const parsed = parseInt(answer.trim(), 10);
55
+ if (!Number.isNaN(parsed) && parsed >= 1 && parsed <= props.entries.length) {
56
+ choice = parsed;
57
+ } else {
58
+ props.logger.log(`Please enter a number between 1 and ${props.entries.length}.`);
59
+ }
60
+ }
61
+ return props.entries[choice - 1] ?? null;
62
+ } finally {
63
+ rl.close();
64
+ }
65
+ }
66
+ function buildProfileEntries(state) {
67
+ const accountById = Object.fromEntries(state.organization.accounts.map((a) => [a.id, a]));
68
+ const permissionSetByArn = Object.fromEntries(
69
+ state.identityCenter.permissionSets.map((ps) => [ps.permissionSetArn, ps])
70
+ );
71
+ const seen = /* @__PURE__ */ new Set();
72
+ const entries = [];
73
+ for (const assignment of state.identityCenter.accountAssignments) {
74
+ const key = `${assignment.accountId}|${assignment.permissionSetArn}`;
75
+ if (seen.has(key)) {
76
+ continue;
77
+ }
78
+ seen.add(key);
79
+ const account = accountById[assignment.accountId];
80
+ const permissionSet = permissionSetByArn[assignment.permissionSetArn];
81
+ if (account == null || permissionSet == null) {
82
+ continue;
83
+ }
84
+ entries.push({
85
+ accountId: account.id,
86
+ accountName: account.name,
87
+ permissionSetName: permissionSet.name
88
+ });
89
+ }
90
+ return entries.sort((a, b) => {
91
+ const accountCmp = a.accountName.localeCompare(b.accountName);
92
+ return accountCmp !== 0 ? accountCmp : a.permissionSetName.localeCompare(b.permissionSetName);
93
+ });
94
+ }
95
+ function buildProfileName(entry) {
96
+ return `${toKebabCase(entry.accountName)}-${toKebabCase(entry.permissionSetName)}`;
97
+ }
98
+ function toKebabCase(value) {
99
+ return value.replace(/([a-z])([A-Z])/g, "$1-$2").replace(/[\s_]+/g, "-").toLowerCase().replace(/[^a-z0-9-]/g, "");
100
+ }
101
+ function renderProfileBlock(props) {
102
+ return [
103
+ `[profile ${props.profileName}]`,
104
+ `sso_session = ${props.ssoSession}`,
105
+ `sso_account_id = ${props.accountId}`,
106
+ `sso_role_name = ${props.roleName}`,
107
+ ``,
108
+ `[sso-session ${props.ssoSession}]`,
109
+ `sso_start_url = ${props.ssoStartUrl}`,
110
+ `sso_region = ${props.region}`,
111
+ `sso_registration_scopes = ${props.ssoRegistrationScopes}`
112
+ ].join("\n");
113
+ }
114
+ export {
115
+ runProfileCommand
116
+ };