@inferdat/crm-auth 0.0.0-stage → 1.0.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/README.md CHANGED
@@ -1,3 +1,283 @@
1
- # Temporary Holding Version
1
+ # crm-auth CLI
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Command-line tool for assuming cross-account AWS roles through the CRM. Get temporary AWS credentials or open the AWS Console for any customer account directly from your terminal.
4
+
5
+ ## Prerequisites
6
+
7
+ - **Node.js 18+** (check with `node -v`)
8
+ - A CRM account with `aws-access:assume` permission (Admin, Manager, or Sales Rep)
9
+ - The customer's AWS account must have ACTIVE access status in the CRM (verified via "Verify Access")
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ npm install -g @inferdat/crm-auth
15
+ ```
16
+
17
+ Requires Node.js 18 or later. To check: `node -v`.
18
+
19
+ To update to the latest version:
20
+
21
+ ```bash
22
+ npm update -g @inferdat/crm-auth
23
+ ```
24
+
25
+ ### Building from source (contributors only)
26
+
27
+ If you're working on the CLI itself:
28
+
29
+ ```bash
30
+ cd cli
31
+ npm install
32
+ npm run build
33
+ npm link # makes `crm-auth` available globally
34
+ ```
35
+
36
+ ## Quick Start
37
+
38
+ ```bash
39
+ # 1. Authenticate (opens browser)
40
+ crm-auth login --url https://portal.inferdat.ai
41
+
42
+ # 2. See what accounts are available
43
+ crm-auth list
44
+
45
+ # 3. Get credentials for a customer account
46
+ crm-auth assume acme-corp-production
47
+
48
+ # 4. Use them
49
+ aws s3 ls --profile acme-corp-production
50
+ ```
51
+
52
+ ## Commands
53
+
54
+ ### `crm-auth login --url <CRM_URL>`
55
+
56
+ Authenticates you with the CRM. Opens your browser to the login page, you sign in, click "Authorize CLI", and the token is saved locally.
57
+
58
+ ```bash
59
+ crm-auth login --url https://portal.inferdat.ai
60
+ ```
61
+
62
+ The `--url` flag is required on first login. After that, the URL is saved in your config.
63
+
64
+ **Multi-workspace support:** If your account has access to multiple workspaces, the CLI prompts you to choose one. All workspaces are saved and you can switch between them without re-authenticating.
65
+
66
+ **What happens:**
67
+ 1. CLI starts a temporary HTTP server on localhost
68
+ 2. Opens your browser to `{CRM_URL}/auth/cli`
69
+ 3. You log in and click "Authorize CLI"
70
+ 4. Token is saved to `~/.crm-auth/config.json` (file permissions: 600)
71
+
72
+ Token is valid for 90 days.
73
+
74
+ ### `crm-auth list`
75
+
76
+ Shows all ACTIVE customer AWS accounts you can access.
77
+
78
+ ```
79
+ Available AWS Accounts (3)
80
+
81
+ Acme Corp
82
+ 761018891308 (Production) [ReadOnly, Administrator] verified 7/11/2026
83
+ slug: acme-corp-production
84
+ crm-auth assume acme-corp-production
85
+
86
+ Globex Inc
87
+ 112233445566 [ReadOnly] verified 7/10/2026
88
+ slug: globex-inc-112233445566
89
+ crm-auth assume globex-inc-112233445566
90
+ ```
91
+
92
+ The **slug** is the stable identifier you pass to `assume` and `console` commands. You can also use the 12-digit AWS account ID directly.
93
+
94
+ ### `crm-auth assume <slug>`
95
+
96
+ Assumes a cross-account role and writes temporary credentials to `~/.aws/credentials` as a named profile.
97
+
98
+ ```bash
99
+ # If only one role available — assumes it automatically
100
+ crm-auth assume acme-corp-production
101
+
102
+ # If multiple roles — prompts you to choose:
103
+ # Multiple roles available:
104
+ # 1. ReadOnly
105
+ # 2. Administrator
106
+ # Select role [1-2]:
107
+
108
+ # Specify role explicitly (by short name)
109
+ crm-auth assume acme-corp-production --role ReadOnly
110
+
111
+ # Custom session duration (in seconds, max 12 hours = 43200)
112
+ crm-auth assume acme-corp-production --role Administrator --duration 3600
113
+
114
+ # Output as env vars instead (for eval or piping)
115
+ crm-auth assume acme-corp-production --env
116
+
117
+ # Just print credentials, don't write to file
118
+ crm-auth assume acme-corp-production --no-profile
119
+ ```
120
+
121
+ After running, use the profile:
122
+
123
+ ```bash
124
+ aws s3 ls --profile acme-corp-production
125
+ aws ec2 describe-instances --profile acme-corp-production --region us-east-1
126
+ ```
127
+
128
+ Or with `--env`:
129
+
130
+ ```bash
131
+ eval $(crm-auth assume acme-corp-production --env)
132
+ aws s3 ls # uses env vars directly
133
+ ```
134
+
135
+ **Options:**
136
+
137
+ | Flag | Description | Default |
138
+ |------|-------------|---------|
139
+ | `-r, --role <name>` | Role short name (e.g. `ReadOnly`, `Administrator`) | Prompts if multiple |
140
+ | `-d, --duration <seconds>` | Session duration (900–43200) | Role's configured max |
141
+ | `--env` | Output `export` statements instead of writing profile | off |
142
+ | `--json` | Output credentials in AWS `credential_process` JSON format | off |
143
+ | `--no-profile` | Print raw credentials without writing to file | off |
144
+
145
+ **Using with `~/.aws/config` (`credential_process`)**
146
+
147
+ The `--json` flag outputs credentials in the exact format AWS expects for [`credential_process`](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-sourcing-external.html), so AWS tools automatically refresh credentials without any wrapper script:
148
+
149
+ ```ini
150
+ # ~/.aws/config
151
+ [profile acme-corp-production]
152
+ credential_process = crm-auth assume acme-corp-production --role ReadOnly --json
153
+ region = us-east-1
154
+ ```
155
+
156
+ Then use any AWS tool normally:
157
+
158
+ ```bash
159
+ aws s3 ls --profile acme-corp-production
160
+ terraform plan # if AWS_PROFILE=acme-corp-production is set
161
+ ```
162
+
163
+ Credentials are fetched on demand and refreshed automatically when they expire.
164
+
165
+ ### `crm-auth console <slug>`
166
+
167
+ Opens the AWS Console in your browser for a customer account. No password needed — uses federation URL.
168
+
169
+ ```bash
170
+ crm-auth console acme-corp-production
171
+ crm-auth console acme-corp-production --role Administrator
172
+ ```
173
+
174
+ Same role selection behavior as `assume`: prompts if multiple roles are available and no `--role` flag is given.
175
+
176
+ **Note:** This opens a new AWS Console session. If you already have a different AWS session in the same browser, it will be replaced. Use a different browser profile or incognito window if you need both.
177
+
178
+ ### `crm-auth status`
179
+
180
+ Shows your current auth status.
181
+
182
+ ```
183
+ CRM CLI Status
184
+
185
+ CRM URL: https://portal.inferdat.ai
186
+ User: sujaiy@inferdat.ai
187
+ Role: Admin
188
+ Token: valid (expires 10/9/2026)
189
+ Workspace: inferdat (active)
190
+ ```
191
+
192
+ ### `crm-auth workspace list`
193
+
194
+ Shows all stored workspaces.
195
+
196
+ ```
197
+ Workspaces:
198
+
199
+ * inferdat (Inferdat)
200
+ acme-partner (Acme Partner Portal)
201
+ ```
202
+
203
+ ### `crm-auth workspace switch <slug>`
204
+
205
+ Switches the active workspace. All subsequent commands operate against the selected workspace.
206
+
207
+ ```bash
208
+ crm-auth workspace switch acme-partner
209
+ ```
210
+
211
+ ### `crm-auth logout`
212
+
213
+ Clears the locally stored token.
214
+
215
+ ## Where Credentials Are Stored
216
+
217
+ | File | Purpose |
218
+ |------|---------|
219
+ | `~/.crm-auth/config.json` | CRM URL, API token, workspace info. Permissions: `0600` |
220
+ | `~/.aws/credentials` | AWS profiles written by `assume`. Permissions: `0600` |
221
+
222
+ ## Roles and Permissions
223
+
224
+ You need these CRM permissions to use the CLI:
225
+
226
+ | CRM Role | Can list accounts | Can assume roles | Can open console |
227
+ |----------|-------------------|------------------|------------------|
228
+ | Admin | ✓ | ✓ | ✓ |
229
+ | Manager | ✓ | ✓ | ✓ |
230
+ | Sales Rep | ✓ | ✓ | ✓ |
231
+ | Read-Only | ✓ | ✗ | ✗ |
232
+
233
+ The AWS role you assume (e.g. `ReadOnly` vs `Administrator`) determines what you can do inside the customer's account. These roles are configured by your admin in Access Settings.
234
+
235
+ ## Role Selection
236
+
237
+ When you have access to multiple roles on an account:
238
+
239
+ - **With `--role` flag**: The CLI resolves the role by short name (case-insensitive). `--role readonly` matches `inferdat-ReadOnly`.
240
+ - **Without `--role` flag**: If only one role is available, it's used automatically. If multiple roles are available, the CLI prompts you to choose.
241
+
242
+ This prevents accidentally assuming a higher-privilege role when you meant to use read-only access.
243
+
244
+ ## Account Slugs
245
+
246
+ Each customer account has a stable slug generated by the server:
247
+ - Format: `{company-name}-{account-name}` (e.g., `acme-corp-production`)
248
+ - If no account name: `{company-name}-{aws-account-id}` (e.g., `globex-inc-112233445566`)
249
+ - Run `crm-auth list` to see all slugs
250
+ - You can also use the raw 12-digit AWS account ID instead of the slug
251
+
252
+ ## Multi-Workspace (Multi-Tenant)
253
+
254
+ If you're on a multi-tenant platform:
255
+ 1. `crm-auth login` shows all workspaces you belong to and prompts you to pick one
256
+ 2. All commands operate against the active workspace
257
+ 3. `crm-auth workspace switch <slug>` changes context without re-authenticating
258
+ 4. Each workspace has its own set of customer accounts
259
+
260
+ ## Troubleshooting
261
+
262
+ **"Not logged in" / "Token expired"**
263
+ ```bash
264
+ crm-auth login --url https://portal.inferdat.ai
265
+ ```
266
+
267
+ **"Permission denied"**
268
+ Your CRM role doesn't have `aws-access:assume`. Contact your admin.
269
+
270
+ **"No active account found"**
271
+ The slug doesn't match any ACTIVE account. Run `crm-auth list` to see available slugs. The account might still be in PENDING status (needs verification in the CRM UI first).
272
+
273
+ **"You don't have access to any roles"**
274
+ Your admin has restricted access to this account. Contact them to be added to the authorization list.
275
+
276
+ **"Account is frozen"**
277
+ An admin froze access to this account. It won't appear in `assume`/`console` commands until unfrozen.
278
+
279
+ **Login callback not working**
280
+ The CLI starts a localhost server for the OAuth callback. If it times out after 5 minutes, check that nothing is blocking localhost connections. The CLI prints the auth URL so you can copy it manually.
281
+
282
+ **Multiple workspaces**
283
+ If you work across multiple workspaces, use `crm-auth workspace switch <slug>` to change context. Run `crm-auth workspace list` to see all options.
package/dist/api.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ import type { Config } from "./config.js";
2
+ export declare class ApiError extends Error {
3
+ status: number;
4
+ constructor(message: string, status: number);
5
+ }
6
+ export declare function api<T = unknown>(config: Config, path: string, options?: RequestInit): Promise<T>;
package/dist/api.js ADDED
@@ -0,0 +1,31 @@
1
+ export class ApiError extends Error {
2
+ status;
3
+ constructor(message, status) {
4
+ super(message);
5
+ this.status = status;
6
+ this.name = "ApiError";
7
+ }
8
+ }
9
+ export async function api(config, path, options = {}) {
10
+ const url = `${config.url}${path}`;
11
+ const headers = {
12
+ Authorization: `Bearer ${config.token}`,
13
+ "Content-Type": "application/json",
14
+ ...options.headers,
15
+ };
16
+ // In multi-tenant mode, include the active workspace slug
17
+ if (config.activeWorkspace) {
18
+ headers["X-Tenant-Slug"] = config.activeWorkspace;
19
+ }
20
+ const res = await fetch(url, {
21
+ ...options,
22
+ headers,
23
+ });
24
+ if (!res.ok) {
25
+ const body = await res.json().catch(() => ({}));
26
+ const msg = body.error || `HTTP ${res.status}`;
27
+ throw new ApiError(msg, res.status);
28
+ }
29
+ const json = (await res.json());
30
+ return json.data;
31
+ }
@@ -0,0 +1,7 @@
1
+ export declare function assume(target: string, options: {
2
+ role?: string;
3
+ duration?: string;
4
+ profile?: boolean;
5
+ env?: boolean;
6
+ json?: boolean;
7
+ }): Promise<void>;
@@ -0,0 +1,206 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ import readline from "node:readline";
5
+ import { requireConfig } from "../config.js";
6
+ import { api } from "../api.js";
7
+ /**
8
+ * Find a matching account by server-generated slug or AWS account ID.
9
+ */
10
+ function findAccount(accounts, target) {
11
+ // Direct AWS account ID match (12 digits)
12
+ const byId = accounts.find((a) => a.awsAccountId === target);
13
+ if (byId)
14
+ return byId;
15
+ // Exact slug match (authoritative, from server)
16
+ const bySlug = accounts.find((a) => a.slug === target);
17
+ if (bySlug)
18
+ return bySlug;
19
+ // Prefix match on slug (for convenience — "acme" matches "acme-production")
20
+ const prefixMatches = accounts.filter((a) => a.slug.startsWith(target + "-") || a.slug === target);
21
+ if (prefixMatches.length === 1)
22
+ return prefixMatches[0];
23
+ if (prefixMatches.length > 1) {
24
+ console.error(`Multiple accounts match "${target}". Be more specific:\n`);
25
+ for (const a of prefixMatches) {
26
+ const name = a.accountName ? ` (${a.accountName})` : "";
27
+ console.error(` ${a.slug} → ${a.awsAccountId}${name}`);
28
+ }
29
+ console.error(`\nUsage: crm-auth assume ${prefixMatches[0].slug}`);
30
+ process.exit(1);
31
+ }
32
+ return null;
33
+ }
34
+ /**
35
+ * Resolve a role name from the --role flag.
36
+ * Accepts: short name (ReadOnly), full name (inferdat-ReadOnly), or partial match.
37
+ * If no flag provided and multiple roles available, prompts the user to choose
38
+ * (only when interactive is true). When interactive is false, uses first role silently.
39
+ */
40
+ async function resolveRoleName(account, roleFlag, interactive) {
41
+ const allowedRoles = account.userAllowedRoles;
42
+ if (allowedRoles.length === 0) {
43
+ console.error("You don't have access to any roles in this account.");
44
+ console.error("Contact your admin to request access.");
45
+ process.exit(1);
46
+ }
47
+ // If --role flag provided, resolve it
48
+ if (roleFlag) {
49
+ // Exact full name match
50
+ const exactMatch = allowedRoles.find((r) => r === roleFlag);
51
+ if (exactMatch)
52
+ return exactMatch;
53
+ // Case-insensitive short name match (strip prefix)
54
+ const shortMatch = allowedRoles.find((r) => {
55
+ const shortName = r.split("-").slice(1).join("-");
56
+ return shortName.toLowerCase() === roleFlag.toLowerCase();
57
+ });
58
+ if (shortMatch)
59
+ return shortMatch;
60
+ // Partial match
61
+ const partialMatch = allowedRoles.find((r) => r.toLowerCase().includes(roleFlag.toLowerCase()));
62
+ if (partialMatch)
63
+ return partialMatch;
64
+ console.error(`Role "${roleFlag}" not found. Available roles:`);
65
+ for (const r of allowedRoles) {
66
+ const shortName = r.split("-").slice(1).join("-");
67
+ console.error(` --role ${shortName}`);
68
+ }
69
+ process.exit(1);
70
+ }
71
+ // No --role flag: if only one role, use it silently
72
+ if (allowedRoles.length === 1) {
73
+ return allowedRoles[0];
74
+ }
75
+ // Multiple roles available but non-interactive — use first role silently
76
+ if (!interactive) {
77
+ const shortName = allowedRoles[0].split("-").slice(1).join("-") || allowedRoles[0];
78
+ process.stderr.write(`(Using first available role: ${shortName}. Specify --role to choose.)\n`);
79
+ return allowedRoles[0];
80
+ }
81
+ // Multiple roles available — prompt user to choose
82
+ console.log("\nMultiple roles available:\n");
83
+ for (let i = 0; i < allowedRoles.length; i++) {
84
+ const shortName = allowedRoles[i].split("-").slice(1).join("-") || allowedRoles[i];
85
+ const desc = account.userAllowedRoleDescriptions?.find((d) => d.roleName === allowedRoles[i])?.description;
86
+ if (desc) {
87
+ console.log(` ${i + 1}. ${shortName} — ${desc}`);
88
+ }
89
+ else {
90
+ console.log(` ${i + 1}. ${shortName}`);
91
+ }
92
+ }
93
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
94
+ const answer = await new Promise((resolve) => {
95
+ rl.question(`\nSelect role [1-${allowedRoles.length}]: `, resolve);
96
+ });
97
+ rl.close();
98
+ const idx = parseInt(answer, 10) - 1;
99
+ if (isNaN(idx) || idx < 0 || idx >= allowedRoles.length) {
100
+ console.log("Invalid selection, using first role.");
101
+ return allowedRoles[0];
102
+ }
103
+ return allowedRoles[idx];
104
+ }
105
+ export async function assume(target, options) {
106
+ // Mutual exclusion: --json and --env can't both be used
107
+ if (options.json && options.env) {
108
+ console.error("Cannot use --json and --env together. Choose one output format.");
109
+ process.exit(1);
110
+ }
111
+ const config = requireConfig();
112
+ // Fetch accounts the user can access
113
+ const result = await api(config, "/api/aws/access-accounts?status=ACTIVE&limit=100");
114
+ const accounts = result.accounts.filter((a) => a.userCanAssume && !a.isFrozen);
115
+ if (accounts.length === 0) {
116
+ console.error("No accessible AWS accounts found.");
117
+ console.error("Run: crm-auth list");
118
+ process.exit(1);
119
+ }
120
+ const account = findAccount(accounts, target);
121
+ if (!account) {
122
+ console.error(`No active account found for "${target}".`);
123
+ console.error("Run: crm-auth list");
124
+ process.exit(1);
125
+ }
126
+ // Non-interactive when --env or --json is used (output is likely piped/eval'd)
127
+ const interactive = !options.env && !options.json;
128
+ const roleName = await resolveRoleName(account, options.role, interactive);
129
+ const durationSeconds = options.duration ? parseInt(options.duration, 10) : undefined;
130
+ const creds = await api(config, "/api/aws/assume-role", {
131
+ method: "POST",
132
+ body: JSON.stringify({
133
+ companyAwsAccountId: account.id,
134
+ accessType: "cli",
135
+ roleName,
136
+ ...(durationSeconds && { sessionDurationSeconds: durationSeconds }),
137
+ }),
138
+ });
139
+ // --json: output credentials in AWS credential_process format (RFC spec)
140
+ // https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-sourcing-external.html
141
+ // AWS requires: Version (int), AccessKeyId, SecretAccessKey, SessionToken, Expiration — all PascalCase.
142
+ // Errors must go to stderr; exit 1 on failure (handled by caller).
143
+ if (options.json) {
144
+ const output = JSON.stringify({
145
+ Version: 1,
146
+ AccessKeyId: creds.accessKeyId,
147
+ SecretAccessKey: creds.secretAccessKey,
148
+ SessionToken: creds.sessionToken,
149
+ Expiration: creds.expiration,
150
+ });
151
+ process.stdout.write(output + "\n");
152
+ return;
153
+ }
154
+ // --env: output export statements for eval
155
+ if (options.env) {
156
+ console.log(creds.envSnippet);
157
+ return;
158
+ }
159
+ // --no-profile: just print raw credentials
160
+ if (options.profile === false) {
161
+ console.log(`AWS_ACCESS_KEY_ID=${creds.accessKeyId}`);
162
+ console.log(`AWS_SECRET_ACCESS_KEY=${creds.secretAccessKey}`);
163
+ console.log(`AWS_SESSION_TOKEN=${creds.sessionToken}`);
164
+ console.log(`# Expires: ${creds.expiration}`);
165
+ return;
166
+ }
167
+ // Default: write to ~/.aws/credentials as a named profile
168
+ const awsDir = path.join(os.homedir(), ".aws");
169
+ const credFile = path.join(awsDir, "credentials");
170
+ const profileName = creds.profileName;
171
+ if (!fs.existsSync(awsDir)) {
172
+ fs.mkdirSync(awsDir, { recursive: true, mode: 0o700 });
173
+ }
174
+ let content = "";
175
+ if (fs.existsSync(credFile)) {
176
+ content = fs.readFileSync(credFile, "utf-8");
177
+ }
178
+ // Remove existing profile section if present
179
+ const profileRegex = new RegExp(`\\[${escapeRegex(profileName)}\\][^\\[]*`, "g");
180
+ content = content.replace(profileRegex, "").trim();
181
+ // Append new profile
182
+ const accountLabel = account.accountName
183
+ ? `${account.accountName} (${account.awsAccountId})`
184
+ : account.awsAccountId;
185
+ const shortRole = roleName.split("-").slice(1).join("-") || roleName;
186
+ const profileBlock = [
187
+ "",
188
+ `[${profileName}]`,
189
+ `aws_access_key_id = ${creds.accessKeyId}`,
190
+ `aws_secret_access_key = ${creds.secretAccessKey}`,
191
+ `aws_session_token = ${creds.sessionToken}`,
192
+ `# crm-auth: ${shortRole} for ${account.company.name} / ${accountLabel}`,
193
+ `# expires ${creds.expiration}`,
194
+ "",
195
+ ].join("\n");
196
+ content = content + "\n" + profileBlock;
197
+ fs.writeFileSync(credFile, content.trim() + "\n", { mode: 0o600 });
198
+ console.log(`\n Credentials written to ~/.aws/credentials [${profileName}]`);
199
+ console.log(` Account: ${account.company.name} / ${accountLabel}`);
200
+ console.log(` Role: ${shortRole}`);
201
+ console.log(` Expires: ${new Date(creds.expiration).toLocaleString()}`);
202
+ console.log(`\n Usage: aws s3 ls --profile ${profileName}`);
203
+ }
204
+ function escapeRegex(s) {
205
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
206
+ }
@@ -0,0 +1,4 @@
1
+ export declare function openConsole(target: string, options: {
2
+ role?: string;
3
+ duration?: string;
4
+ }): Promise<void>;
@@ -0,0 +1,120 @@
1
+ import readline from "node:readline";
2
+ import open from "open";
3
+ import { requireConfig } from "../config.js";
4
+ import { api } from "../api.js";
5
+ /**
6
+ * Find a matching account by server-generated slug or AWS account ID.
7
+ */
8
+ function findAccount(accounts, target) {
9
+ const byId = accounts.find((a) => a.awsAccountId === target);
10
+ if (byId)
11
+ return byId;
12
+ const bySlug = accounts.find((a) => a.slug === target);
13
+ if (bySlug)
14
+ return bySlug;
15
+ const prefixMatches = accounts.filter((a) => a.slug.startsWith(target + "-") || a.slug === target);
16
+ if (prefixMatches.length === 1)
17
+ return prefixMatches[0];
18
+ if (prefixMatches.length > 1) {
19
+ console.error(`Multiple accounts match "${target}". Be more specific:\n`);
20
+ for (const a of prefixMatches) {
21
+ const name = a.accountName ? ` (${a.accountName})` : "";
22
+ console.error(` ${a.slug} → ${a.awsAccountId}${name}`);
23
+ }
24
+ console.error(`\nUsage: crm-auth console ${prefixMatches[0].slug}`);
25
+ process.exit(1);
26
+ }
27
+ return null;
28
+ }
29
+ /**
30
+ * Resolve a role name from the --role flag or prompt user.
31
+ * When stdout is not a TTY (piped/scripted), skips prompting and uses first role.
32
+ */
33
+ async function resolveRoleName(account, roleFlag) {
34
+ const allowedRoles = account.userAllowedRoles;
35
+ if (allowedRoles.length === 0) {
36
+ console.error("You don't have access to any roles in this account.");
37
+ console.error("Contact your admin to request access.");
38
+ process.exit(1);
39
+ }
40
+ if (roleFlag) {
41
+ const exactMatch = allowedRoles.find((r) => r === roleFlag);
42
+ if (exactMatch)
43
+ return exactMatch;
44
+ const shortMatch = allowedRoles.find((r) => {
45
+ const shortName = r.split("-").slice(1).join("-");
46
+ return shortName.toLowerCase() === roleFlag.toLowerCase();
47
+ });
48
+ if (shortMatch)
49
+ return shortMatch;
50
+ const partialMatch = allowedRoles.find((r) => r.toLowerCase().includes(roleFlag.toLowerCase()));
51
+ if (partialMatch)
52
+ return partialMatch;
53
+ console.error(`Role "${roleFlag}" not found. Available roles:`);
54
+ for (const r of allowedRoles) {
55
+ const shortName = r.split("-").slice(1).join("-");
56
+ console.error(` --role ${shortName}`);
57
+ }
58
+ process.exit(1);
59
+ }
60
+ if (allowedRoles.length === 1) {
61
+ return allowedRoles[0];
62
+ }
63
+ // Multiple roles available but non-interactive (not a TTY) — use first role silently
64
+ if (!process.stdout.isTTY) {
65
+ const shortName = allowedRoles[0].split("-").slice(1).join("-") || allowedRoles[0];
66
+ process.stderr.write(`(Using first available role: ${shortName}. Specify --role to choose.)\n`);
67
+ return allowedRoles[0];
68
+ }
69
+ // Multiple roles — prompt
70
+ console.log("\nMultiple roles available:\n");
71
+ for (let i = 0; i < allowedRoles.length; i++) {
72
+ const shortName = allowedRoles[i].split("-").slice(1).join("-") || allowedRoles[i];
73
+ console.log(` ${i + 1}. ${shortName}`);
74
+ }
75
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
76
+ const answer = await new Promise((resolve) => {
77
+ rl.question(`\nSelect role [1-${allowedRoles.length}]: `, resolve);
78
+ });
79
+ rl.close();
80
+ const idx = parseInt(answer, 10) - 1;
81
+ if (isNaN(idx) || idx < 0 || idx >= allowedRoles.length) {
82
+ console.log("Invalid selection, using first role.");
83
+ return allowedRoles[0];
84
+ }
85
+ return allowedRoles[idx];
86
+ }
87
+ export async function openConsole(target, options) {
88
+ const config = requireConfig();
89
+ const result = await api(config, "/api/aws/access-accounts?status=ACTIVE&limit=100");
90
+ const accounts = result.accounts.filter((a) => a.userCanAssume && !a.isFrozen);
91
+ if (accounts.length === 0) {
92
+ console.error("No accessible AWS accounts found.");
93
+ console.error("Run: crm-auth list");
94
+ process.exit(1);
95
+ }
96
+ const account = findAccount(accounts, target);
97
+ if (!account) {
98
+ console.error(`No active account found for "${target}".`);
99
+ console.error("Run: crm-auth list");
100
+ process.exit(1);
101
+ }
102
+ const roleName = await resolveRoleName(account, options.role);
103
+ const durationSeconds = options.duration ? parseInt(options.duration, 10) : undefined;
104
+ const consoleResult = await api(config, "/api/aws/assume-role", {
105
+ method: "POST",
106
+ body: JSON.stringify({
107
+ companyAwsAccountId: account.id,
108
+ accessType: "console",
109
+ roleName,
110
+ ...(durationSeconds && { sessionDurationSeconds: durationSeconds }),
111
+ }),
112
+ });
113
+ const accountLabel = account.accountName
114
+ ? `${account.accountName} (${account.awsAccountId})`
115
+ : account.awsAccountId;
116
+ const shortRole = roleName.split("-").slice(1).join("-") || roleName;
117
+ console.log(`\nOpening AWS Console for ${account.company.name} / ${accountLabel} (${shortRole})...`);
118
+ console.log(`Session expires: ${new Date(consoleResult.expiration).toLocaleString()}`);
119
+ await open(consoleResult.consoleUrl);
120
+ }
@@ -0,0 +1,3 @@
1
+ export declare function list(options?: {
2
+ company?: string;
3
+ }): Promise<void>;
@@ -0,0 +1,43 @@
1
+ import { requireConfig } from "../config.js";
2
+ import { api } from "../api.js";
3
+ export async function list(options) {
4
+ const config = requireConfig();
5
+ let url = "/api/aws/access-accounts?status=ACTIVE&limit=100";
6
+ if (options?.company) {
7
+ url += `&companyName=${encodeURIComponent(options.company)}`;
8
+ }
9
+ const result = await api(config, url);
10
+ const accounts = result.accounts;
11
+ if (accounts.length === 0) {
12
+ console.log("\n No active AWS accounts available.");
13
+ console.log(" Accounts must be set up and verified in the CRM before they appear here.\n");
14
+ return;
15
+ }
16
+ console.log(`\n Available AWS Accounts (${accounts.length})\n`);
17
+ // Group by company
18
+ const grouped = new Map();
19
+ for (const a of accounts) {
20
+ const list = grouped.get(a.company.name) || [];
21
+ list.push(a);
22
+ grouped.set(a.company.name, list);
23
+ }
24
+ for (const [company, accts] of grouped) {
25
+ console.log(` ${company}`);
26
+ for (const a of accts) {
27
+ // Show roles as short names (strip partner slug prefix)
28
+ const roles = a.userAllowedRoles
29
+ .map((r) => r.split("-").slice(1).join("-") || r)
30
+ .join(", ");
31
+ const name = a.accountName ? ` (${a.accountName})` : "";
32
+ const frozen = a.isFrozen ? " [FROZEN]" : "";
33
+ const verified = a.accessVerifiedAt
34
+ ? `verified ${new Date(a.accessVerifiedAt).toLocaleDateString()}`
35
+ : "not verified";
36
+ const access = a.userCanAssume ? "" : " (no access)";
37
+ console.log(` ${a.awsAccountId}${name}${frozen} [${roles || "no roles"}] ${verified}${access}`);
38
+ console.log(` slug: ${a.slug}`);
39
+ console.log(` crm-auth assume ${a.slug}`);
40
+ }
41
+ console.log();
42
+ }
43
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Start a temporary localhost HTTP server, open the CRM auth page in the
3
+ * browser, and wait for the redirect callback with the token.
4
+ */
5
+ export declare function login(crmUrl: string): Promise<void>;
@@ -0,0 +1,154 @@
1
+ import http from "node:http";
2
+ import crypto from "node:crypto";
3
+ import readline from "node:readline";
4
+ import open from "open";
5
+ import { saveWorkspace } from "../config.js";
6
+ /**
7
+ * Prompt the user to pick a workspace from a numbered list.
8
+ * Returns the selected workspace object.
9
+ */
10
+ async function pickWorkspace(workspaces) {
11
+ if (workspaces.length === 1) {
12
+ return workspaces[0];
13
+ }
14
+ console.log("\nAvailable workspaces:\n");
15
+ for (let i = 0; i < workspaces.length; i++) {
16
+ console.log(` ${i + 1}. ${workspaces[i].name} (${workspaces[i].slug})`);
17
+ }
18
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
19
+ const answer = await new Promise((resolve) => {
20
+ rl.question(`\nSelect workspace [1-${workspaces.length}]: `, resolve);
21
+ });
22
+ rl.close();
23
+ const idx = parseInt(answer, 10) - 1;
24
+ if (isNaN(idx) || idx < 0 || idx >= workspaces.length) {
25
+ console.log("Invalid selection, using first workspace.");
26
+ return workspaces[0];
27
+ }
28
+ return workspaces[idx];
29
+ }
30
+ /**
31
+ * Start a temporary localhost HTTP server, open the CRM auth page in the
32
+ * browser, and wait for the redirect callback with the token.
33
+ */
34
+ export async function login(crmUrl) {
35
+ // Normalise URL — strip trailing slash
36
+ const baseUrl = crmUrl.replace(/\/+$/, "");
37
+ const state = crypto.randomBytes(16).toString("hex");
38
+ let timeoutHandle;
39
+ // Find a free port by binding to 0
40
+ const { token, user, expiresAt, workspaces } = await new Promise((resolve, reject) => {
41
+ const connections = new Set();
42
+ const server = http.createServer((req, res) => {
43
+ const url = new URL(req.url || "/", `http://localhost`);
44
+ if (url.pathname !== "/callback") {
45
+ res.writeHead(404);
46
+ res.end("Not found");
47
+ return;
48
+ }
49
+ const receivedToken = url.searchParams.get("token");
50
+ const receivedState = url.searchParams.get("state");
51
+ if (receivedState !== state) {
52
+ res.writeHead(400);
53
+ res.end("State mismatch — possible CSRF. Please try again.");
54
+ destroyServer();
55
+ reject(new Error("State mismatch"));
56
+ return;
57
+ }
58
+ if (!receivedToken) {
59
+ res.writeHead(400);
60
+ res.end("No token received. Authorization may have been denied.");
61
+ destroyServer();
62
+ reject(new Error("No token received"));
63
+ return;
64
+ }
65
+ // Send a nice HTML response before closing
66
+ res.writeHead(200, { "Content-Type": "text/html" });
67
+ res.end(`
68
+ <html>
69
+ <body style="font-family: system-ui, sans-serif; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0;">
70
+ <div style="text-align: center;">
71
+ <h2 style="color: #16a34a;">&#10003; Authenticated</h2>
72
+ <p>You can close this tab and return to your terminal.</p>
73
+ </div>
74
+ </body>
75
+ </html>
76
+ `);
77
+ // Now validate the token by calling /api/auth/cli/me
78
+ fetch(`${baseUrl}/api/auth/cli/me`, {
79
+ headers: { Authorization: `Bearer ${receivedToken}` },
80
+ })
81
+ .then(async (meRes) => {
82
+ if (!meRes.ok) {
83
+ throw new Error("Token validation failed");
84
+ }
85
+ const json = (await meRes.json());
86
+ destroyServer();
87
+ resolve({
88
+ token: receivedToken,
89
+ user: { email: json.data.email, name: json.data.name, role: json.data.role },
90
+ expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(),
91
+ workspaces: json.data.workspaces || [{ slug: "default", name: "Default Workspace" }],
92
+ });
93
+ })
94
+ .catch((err) => {
95
+ destroyServer();
96
+ reject(err);
97
+ });
98
+ });
99
+ // Track connections so we can force-destroy them on close
100
+ server.on("connection", (socket) => {
101
+ connections.add(socket);
102
+ socket.on("close", () => connections.delete(socket));
103
+ });
104
+ function destroyServer() {
105
+ if (timeoutHandle)
106
+ clearTimeout(timeoutHandle);
107
+ for (const socket of connections) {
108
+ socket.destroy();
109
+ }
110
+ server.close();
111
+ }
112
+ // Listen on a random available port
113
+ server.listen(0, "127.0.0.1", () => {
114
+ const addr = server.address();
115
+ const p = typeof addr === "object" && addr ? addr.port : 0;
116
+ const authUrl = `${baseUrl}/auth/cli?redirect_uri=${encodeURIComponent(`http://localhost:${p}/callback`)}&state=${state}`;
117
+ console.log(`Opening browser to authenticate...`);
118
+ console.log(`If the browser doesn't open, visit:\n ${authUrl}\n`);
119
+ open(authUrl).catch(() => {
120
+ // If open fails (e.g. headless server), user can copy the URL
121
+ });
122
+ // Timeout after 5 minutes
123
+ timeoutHandle = setTimeout(() => {
124
+ destroyServer();
125
+ reject(new Error("Login timed out after 5 minutes"));
126
+ }, 5 * 60 * 1000);
127
+ timeoutHandle.unref();
128
+ });
129
+ server.on("error", reject);
130
+ });
131
+ // If multiple workspaces, let the user pick one
132
+ const selected = await pickWorkspace(workspaces);
133
+ // Save workspace to config
134
+ const wsConfig = {
135
+ token,
136
+ name: selected.name,
137
+ expiresAt,
138
+ user,
139
+ };
140
+ saveWorkspace(baseUrl, selected.slug, wsConfig, true);
141
+ // Also save other workspaces (with the same token — server determines access)
142
+ for (const ws of workspaces) {
143
+ if (ws.slug !== selected.slug) {
144
+ saveWorkspace(baseUrl, ws.slug, { token, name: ws.name, expiresAt, user }, false);
145
+ }
146
+ }
147
+ console.log(`\nLogged in as ${user.email} (${user.role})`);
148
+ console.log(`Active workspace: ${selected.name} (${selected.slug})`);
149
+ console.log(`Token expires: ${new Date(expiresAt).toLocaleDateString()}`);
150
+ console.log(`CRM: ${baseUrl}`);
151
+ if (workspaces.length > 1) {
152
+ console.log(`\n${workspaces.length} workspaces available. Switch with: crm-auth workspace switch <slug>`);
153
+ }
154
+ }
@@ -0,0 +1 @@
1
+ export declare function logout(): void;
@@ -0,0 +1,10 @@
1
+ import { clearConfig, loadConfig } from "../config.js";
2
+ export function logout() {
3
+ const config = loadConfig();
4
+ if (!config) {
5
+ console.log("Not logged in.");
6
+ return;
7
+ }
8
+ clearConfig();
9
+ console.log(`Logged out from ${config.url} (${config.user.email})`);
10
+ }
@@ -0,0 +1 @@
1
+ export declare function status(): Promise<void>;
@@ -0,0 +1,33 @@
1
+ import { loadConfig } from "../config.js";
2
+ import { api, ApiError } from "../api.js";
3
+ export async function status() {
4
+ const config = loadConfig();
5
+ if (!config) {
6
+ console.log("Not logged in. Run: crm-auth login --url <CRM_URL>");
7
+ return;
8
+ }
9
+ const expired = new Date(config.expiresAt) < new Date();
10
+ console.log(`\n CRM CLI Status\n`);
11
+ console.log(` CRM URL: ${config.url}`);
12
+ console.log(` User: ${config.user.email}`);
13
+ console.log(` Role: ${config.user.role}`);
14
+ console.log(` Token: ${expired ? "EXPIRED" : "valid"} (expires ${new Date(config.expiresAt).toLocaleDateString()})`);
15
+ if (expired) {
16
+ console.log(`\n Token expired. Run: crm-auth login --url ${config.url}`);
17
+ return;
18
+ }
19
+ // Verify token is still valid server-side
20
+ try {
21
+ const me = await api(config, "/api/auth/cli/me");
22
+ console.log(` Server: connected (${me.email})`);
23
+ }
24
+ catch (err) {
25
+ if (err instanceof ApiError && err.status === 401) {
26
+ console.log(` Server: token revoked or invalid`);
27
+ }
28
+ else {
29
+ console.log(` Server: unreachable`);
30
+ }
31
+ }
32
+ console.log();
33
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * List all stored workspaces and indicate which is active.
3
+ */
4
+ export declare function workspaceList(): void;
5
+ /**
6
+ * Switch the active workspace to the given slug.
7
+ */
8
+ export declare function workspaceSwitch(slug: string): void;
@@ -0,0 +1,30 @@
1
+ import { listWorkspaces, switchWorkspace } from "../config.js";
2
+ /**
3
+ * List all stored workspaces and indicate which is active.
4
+ */
5
+ export function workspaceList() {
6
+ const workspaces = listWorkspaces();
7
+ if (workspaces.length === 0) {
8
+ console.log("No workspaces configured. Run: crm-auth login --url <CRM_URL>");
9
+ return;
10
+ }
11
+ console.log("\nWorkspaces:\n");
12
+ for (const ws of workspaces) {
13
+ const marker = ws.active ? " * " : " ";
14
+ console.log(`${marker}${ws.slug} (${ws.name})${ws.active ? " ← active" : ""}`);
15
+ }
16
+ console.log("");
17
+ }
18
+ /**
19
+ * Switch the active workspace to the given slug.
20
+ */
21
+ export function workspaceSwitch(slug) {
22
+ const success = switchWorkspace(slug);
23
+ if (success) {
24
+ console.log(`Switched to workspace: ${slug}`);
25
+ }
26
+ else {
27
+ console.error(`Workspace "${slug}" not found. Run: crm-auth workspace list`);
28
+ process.exit(1);
29
+ }
30
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Per-workspace stored credentials.
3
+ */
4
+ export interface WorkspaceConfig {
5
+ token: string;
6
+ name: string;
7
+ expiresAt: string;
8
+ user: {
9
+ email: string;
10
+ name: string | null;
11
+ role: string;
12
+ };
13
+ }
14
+ /**
15
+ * Multi-workspace config file format.
16
+ */
17
+ export interface MultiWorkspaceFile {
18
+ url: string;
19
+ activeWorkspace: string;
20
+ workspaces: Record<string, WorkspaceConfig>;
21
+ }
22
+ /**
23
+ * The runtime config shape exposed to commands.
24
+ */
25
+ export interface Config {
26
+ url: string;
27
+ token: string;
28
+ expiresAt: string;
29
+ activeWorkspace: string | null;
30
+ user: {
31
+ email: string;
32
+ name: string | null;
33
+ role: string;
34
+ };
35
+ }
36
+ export declare function loadConfig(): Config | null;
37
+ export declare function loadMultiConfig(): MultiWorkspaceFile | null;
38
+ export declare function saveWorkspace(url: string, slug: string, workspace: WorkspaceConfig, setActive?: boolean): void;
39
+ export declare function switchWorkspace(slug: string): boolean;
40
+ export declare function listWorkspaces(): {
41
+ slug: string;
42
+ name: string;
43
+ active: boolean;
44
+ }[];
45
+ export declare function clearConfig(): void;
46
+ export declare function requireConfig(): Config;
package/dist/config.js ADDED
@@ -0,0 +1,129 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ const CONFIG_DIR = path.join(os.homedir(), ".crm-auth");
5
+ const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
6
+ function ensureDir() {
7
+ if (!fs.existsSync(CONFIG_DIR)) {
8
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
9
+ }
10
+ }
11
+ function readRawConfig() {
12
+ try {
13
+ if (!fs.existsSync(CONFIG_FILE))
14
+ return null;
15
+ const raw = fs.readFileSync(CONFIG_FILE, "utf-8");
16
+ return JSON.parse(raw);
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ function isMultiWorkspace(raw) {
23
+ return "workspaces" in raw && "activeWorkspace" in raw;
24
+ }
25
+ /**
26
+ * Migrate legacy format to multi-workspace on read.
27
+ */
28
+ function migrateLegacy(legacy) {
29
+ const defaultSlug = "default";
30
+ const multi = {
31
+ url: legacy.url,
32
+ activeWorkspace: defaultSlug,
33
+ workspaces: {
34
+ [defaultSlug]: {
35
+ token: legacy.token,
36
+ name: "Default Workspace",
37
+ expiresAt: legacy.expiresAt,
38
+ user: legacy.user,
39
+ },
40
+ },
41
+ };
42
+ // Persist the migration
43
+ writeRawConfig(multi);
44
+ return multi;
45
+ }
46
+ function writeRawConfig(config) {
47
+ ensureDir();
48
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), {
49
+ mode: 0o600,
50
+ });
51
+ }
52
+ export function loadConfig() {
53
+ const raw = readRawConfig();
54
+ if (!raw)
55
+ return null;
56
+ const multi = isMultiWorkspace(raw) ? raw : migrateLegacy(raw);
57
+ const slug = multi.activeWorkspace;
58
+ const ws = multi.workspaces[slug];
59
+ if (!ws)
60
+ return null;
61
+ return {
62
+ url: multi.url,
63
+ token: ws.token,
64
+ expiresAt: ws.expiresAt,
65
+ activeWorkspace: slug,
66
+ user: ws.user,
67
+ };
68
+ }
69
+ export function loadMultiConfig() {
70
+ const raw = readRawConfig();
71
+ if (!raw)
72
+ return null;
73
+ return isMultiWorkspace(raw) ? raw : migrateLegacy(raw);
74
+ }
75
+ export function saveWorkspace(url, slug, workspace, setActive = true) {
76
+ let multi = loadMultiConfig();
77
+ if (!multi) {
78
+ multi = { url, activeWorkspace: slug, workspaces: {} };
79
+ }
80
+ multi.url = url;
81
+ multi.workspaces[slug] = workspace;
82
+ if (setActive) {
83
+ multi.activeWorkspace = slug;
84
+ }
85
+ writeRawConfig(multi);
86
+ }
87
+ export function switchWorkspace(slug) {
88
+ const multi = loadMultiConfig();
89
+ if (!multi)
90
+ return false;
91
+ if (!multi.workspaces[slug])
92
+ return false;
93
+ multi.activeWorkspace = slug;
94
+ writeRawConfig(multi);
95
+ return true;
96
+ }
97
+ export function listWorkspaces() {
98
+ const multi = loadMultiConfig();
99
+ if (!multi)
100
+ return [];
101
+ return Object.entries(multi.workspaces).map(([slug, ws]) => ({
102
+ slug,
103
+ name: ws.name,
104
+ active: slug === multi.activeWorkspace,
105
+ }));
106
+ }
107
+ export function clearConfig() {
108
+ try {
109
+ if (fs.existsSync(CONFIG_FILE)) {
110
+ fs.unlinkSync(CONFIG_FILE);
111
+ }
112
+ }
113
+ catch {
114
+ // ignore
115
+ }
116
+ }
117
+ export function requireConfig() {
118
+ const config = loadConfig();
119
+ if (!config) {
120
+ console.error("Not logged in. Run: crm-auth login --url <CRM_URL>");
121
+ process.exit(1);
122
+ }
123
+ if (new Date(config.expiresAt) < new Date()) {
124
+ console.error("Token expired. Run: crm-auth login --url " + config.url);
125
+ clearConfig();
126
+ process.exit(1);
127
+ }
128
+ return config;
129
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { login } from "./commands/login.js";
4
+ import { list } from "./commands/list.js";
5
+ import { assume } from "./commands/assume.js";
6
+ import { openConsole } from "./commands/console.js";
7
+ import { status } from "./commands/status.js";
8
+ import { logout } from "./commands/logout.js";
9
+ import { workspaceList, workspaceSwitch } from "./commands/workspace.js";
10
+ import { ApiError } from "./api.js";
11
+ const program = new Command();
12
+ program
13
+ .name("crm-auth")
14
+ .description("CLI tool for CRM cross-account AWS access")
15
+ .version("1.0.0");
16
+ program
17
+ .command("login")
18
+ .description("Authenticate with the CRM via browser")
19
+ .requiredOption("--url <url>", "CRM base URL (e.g. https://crm.example.com)")
20
+ .action(async (opts) => {
21
+ await login(opts.url);
22
+ });
23
+ program
24
+ .command("list")
25
+ .description("List available customer AWS accounts")
26
+ .option("-c, --company <name>", "Filter by company name")
27
+ .action(async (opts) => {
28
+ await list(opts);
29
+ });
30
+ program
31
+ .command("assume <company>")
32
+ .description("Assume role and write credentials to ~/.aws/credentials")
33
+ .option("-r, --role <name>", "Role short name (e.g. ReadOnly, Administrator)")
34
+ .option("-d, --duration <seconds>", "Session duration in seconds")
35
+ .option("--env", "Output as environment variable exports")
36
+ .option("--json", "Output credentials as JSON (for scripting)")
37
+ .option("--no-profile", "Print credentials without writing to file")
38
+ .action(async (company, opts) => {
39
+ await assume(company, opts);
40
+ });
41
+ program
42
+ .command("console <company>")
43
+ .description("Open AWS Console in browser for a customer account")
44
+ .option("-r, --role <name>", "Role short name (e.g. ReadOnly, Administrator)")
45
+ .option("-d, --duration <seconds>", "Session duration in seconds")
46
+ .action(async (company, opts) => {
47
+ await openConsole(company, opts);
48
+ });
49
+ program
50
+ .command("status")
51
+ .description("Show current authentication status")
52
+ .action(async () => {
53
+ await status();
54
+ });
55
+ program
56
+ .command("logout")
57
+ .description("Clear cached authentication token")
58
+ .action(() => {
59
+ logout();
60
+ });
61
+ // Workspace commands
62
+ const workspace = program
63
+ .command("workspace")
64
+ .description("Manage workspaces");
65
+ workspace
66
+ .command("list")
67
+ .description("List stored workspaces")
68
+ .action(() => {
69
+ workspaceList();
70
+ });
71
+ workspace
72
+ .command("switch <slug>")
73
+ .description("Switch the active workspace")
74
+ .action((slug) => {
75
+ workspaceSwitch(slug);
76
+ });
77
+ // Global error handler
78
+ program.hook("postAction", () => { });
79
+ async function main() {
80
+ try {
81
+ await program.parseAsync(process.argv);
82
+ }
83
+ catch (err) {
84
+ if (err instanceof ApiError) {
85
+ if (err.status === 401) {
86
+ console.error("Authentication failed. Run: crm-auth login --url <CRM_URL>");
87
+ }
88
+ else if (err.status === 403) {
89
+ console.error(`Permission denied: ${err.message}`);
90
+ }
91
+ else {
92
+ console.error(`API error (${err.status}): ${err.message}`);
93
+ }
94
+ }
95
+ else if (err instanceof Error) {
96
+ console.error(`Error: ${err.message}`);
97
+ }
98
+ process.exit(1);
99
+ }
100
+ }
101
+ main();
package/package.json CHANGED
@@ -1,6 +1,32 @@
1
1
  {
2
2
  "name": "@inferdat/crm-auth",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "CLI tool for Inferdat CRM cross-account AWS access",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=18.0.0"
8
+ },
9
+ "bin": {
10
+ "crm-auth": "./dist/index.js"
11
+ },
12
+ "files": [
13
+ "dist/",
14
+ "README.md"
15
+ ],
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "scripts": {
20
+ "build": "tsc",
21
+ "dev": "tsc --watch",
22
+ "start": "node dist/index.js"
23
+ },
24
+ "dependencies": {
25
+ "commander": "^13.1.0",
26
+ "open": "^10.1.0"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^20.0.0",
30
+ "typescript": "^5.0.0"
31
+ }
32
+ }