@profoundlogic/coderflow-cli 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/oidc.js ADDED
@@ -0,0 +1,126 @@
1
+ /**
2
+ * OIDC Device Flow support for CLI SSO authentication
3
+ */
4
+
5
+ import { getServerUrl } from './config.js';
6
+
7
+ /**
8
+ * Check if OIDC is configured on the server
9
+ * @returns {Promise<{enabled: boolean, provider?: string}>}
10
+ */
11
+ export async function checkOidcConfig() {
12
+ const serverUrl = await getServerUrl();
13
+ const url = `${serverUrl}/auth/oidc/config`;
14
+
15
+ const response = await fetch(url, {
16
+ method: 'GET',
17
+ headers: {
18
+ 'Content-Type': 'application/json'
19
+ }
20
+ });
21
+
22
+ if (!response.ok) {
23
+ if (response.status === 404) {
24
+ return { enabled: false };
25
+ }
26
+ throw new Error(`Failed to check OIDC config: ${response.status} ${response.statusText}`);
27
+ }
28
+
29
+ const data = await response.json();
30
+ return {
31
+ enabled: data.enabled === true,
32
+ provider: data.provider
33
+ };
34
+ }
35
+
36
+ /**
37
+ * Initiate the OIDC device flow
38
+ * @returns {Promise<{deviceCode: string, userCode: string, verificationUri: string, expiresIn: number, interval: number}>}
39
+ */
40
+ export async function initiateDeviceFlow() {
41
+ const serverUrl = await getServerUrl();
42
+ const url = `${serverUrl}/auth/oidc/cli-init`;
43
+
44
+ const response = await fetch(url, {
45
+ method: 'POST',
46
+ headers: {
47
+ 'Content-Type': 'application/json'
48
+ }
49
+ });
50
+
51
+ if (!response.ok) {
52
+ const errorText = await response.text();
53
+ let errorMessage;
54
+ try {
55
+ const errorJson = JSON.parse(errorText);
56
+ errorMessage = errorJson.error || errorJson.message || errorText;
57
+ } catch {
58
+ errorMessage = errorText;
59
+ }
60
+ throw new Error(`Failed to initiate device flow: ${errorMessage}`);
61
+ }
62
+
63
+ const data = await response.json();
64
+ return {
65
+ deviceCode: data.device_code,
66
+ userCode: data.user_code,
67
+ verificationUrl: data.verification_url,
68
+ expiresIn: data.expires_in || 600,
69
+ interval: data.interval || 5
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Poll for device flow approval
75
+ * @param {string} deviceCode - The device code from initiateDeviceFlow
76
+ * @returns {Promise<{status: 'pending' | 'approved' | 'expired' | 'denied', apiKey?: string, user?: object}>}
77
+ */
78
+ export async function pollDeviceFlow(deviceCode) {
79
+ const serverUrl = await getServerUrl();
80
+ const url = `${serverUrl}/auth/oidc/cli-poll`;
81
+
82
+ const response = await fetch(url, {
83
+ method: 'POST',
84
+ headers: {
85
+ 'Content-Type': 'application/json'
86
+ },
87
+ body: JSON.stringify({ device_code: deviceCode })
88
+ });
89
+
90
+ if (!response.ok) {
91
+ const errorText = await response.text();
92
+ let errorMessage;
93
+ try {
94
+ const errorJson = JSON.parse(errorText);
95
+ errorMessage = errorJson.error || errorJson.message || errorText;
96
+ } catch {
97
+ errorMessage = errorText;
98
+ }
99
+
100
+ // Handle specific error cases
101
+ if (response.status === 400) {
102
+ if (errorMessage.includes('expired')) {
103
+ return { status: 'expired' };
104
+ }
105
+ if (errorMessage.includes('denied')) {
106
+ return { status: 'denied' };
107
+ }
108
+ }
109
+
110
+ throw new Error(`Failed to poll device flow: ${errorMessage}`);
111
+ }
112
+
113
+ const data = await response.json();
114
+
115
+ if (data.status === 'approved' || data.apiKey) {
116
+ return {
117
+ status: 'approved',
118
+ apiKey: data.apiKey,
119
+ user: data.user
120
+ };
121
+ }
122
+
123
+ return {
124
+ status: data.status || 'pending'
125
+ };
126
+ }
package/lib/profile.js ADDED
@@ -0,0 +1,296 @@
1
+ /**
2
+ * Profile management for Coder CLI
3
+ *
4
+ * Profiles allow users to maintain multiple CLI configurations
5
+ * for different CoderFlow servers and switch between them easily.
6
+ *
7
+ * Profile structure:
8
+ * ~/.coder/
9
+ * ├── config.json # Global config + activeProfile pointer
10
+ * └── profiles/
11
+ * ├── my-server.json
12
+ * ├── team-name.json
13
+ * └── project-x.json
14
+ */
15
+
16
+ import { promises as fs } from 'fs';
17
+ import os from 'os';
18
+ import path from 'path';
19
+
20
+ // Profile-specific configuration keys
21
+ const PROFILE_CONFIG_KEYS = [
22
+ 'server',
23
+ 'apiKey',
24
+ 'default_environment',
25
+ 'coder_setup_path',
26
+ 'server_port',
27
+ 'profound_coder_path'
28
+ ];
29
+
30
+ /**
31
+ * Get the base coder config directory
32
+ */
33
+ export function getCoderConfigDir() {
34
+ return process.env.CODER_CONFIG_PATH
35
+ ? path.dirname(process.env.CODER_CONFIG_PATH)
36
+ : path.join(os.homedir(), '.coder');
37
+ }
38
+
39
+ /**
40
+ * Get the profiles directory path
41
+ */
42
+ export function getProfilesDir() {
43
+ return path.join(getCoderConfigDir(), 'profiles');
44
+ }
45
+
46
+ /**
47
+ * Get path to a specific profile file
48
+ */
49
+ export function getProfilePath(profileName) {
50
+ return path.join(getProfilesDir(), `${profileName}.json`);
51
+ }
52
+
53
+ /**
54
+ * Get the main config.json path
55
+ */
56
+ export function getMainConfigPath() {
57
+ return process.env.CODER_CONFIG_PATH || path.join(getCoderConfigDir(), 'config.json');
58
+ }
59
+
60
+ /**
61
+ * Ensure the profiles directory exists
62
+ */
63
+ export async function ensureProfilesDir() {
64
+ const profilesDir = getProfilesDir();
65
+ await fs.mkdir(profilesDir, { recursive: true });
66
+ }
67
+
68
+ /**
69
+ * Load the main config.json (contains activeProfile and legacy settings)
70
+ */
71
+ export async function loadMainConfig() {
72
+ const configPath = getMainConfigPath();
73
+ try {
74
+ const content = await fs.readFile(configPath, 'utf-8');
75
+ return JSON.parse(content);
76
+ } catch (error) {
77
+ if (error.code === 'ENOENT') {
78
+ return null;
79
+ }
80
+ throw new Error(`Failed to read config at ${configPath}: ${error.message}`);
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Save the main config.json
86
+ */
87
+ export async function saveMainConfig(config) {
88
+ const configPath = getMainConfigPath();
89
+ const configDir = path.dirname(configPath);
90
+ await fs.mkdir(configDir, { recursive: true });
91
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
92
+ }
93
+
94
+ /**
95
+ * Get the active profile name
96
+ * Priority: CODER_PROFILE env var > config.json activeProfile > null
97
+ */
98
+ export async function getActiveProfileName() {
99
+ // Check environment variable first
100
+ if (process.env.CODER_PROFILE) {
101
+ return process.env.CODER_PROFILE;
102
+ }
103
+
104
+ // Check main config
105
+ const mainConfig = await loadMainConfig();
106
+ return mainConfig?.activeProfile || null;
107
+ }
108
+
109
+ /**
110
+ * Set the active profile in config.json
111
+ */
112
+ export async function setActiveProfile(profileName) {
113
+ const mainConfig = await loadMainConfig() || {};
114
+ mainConfig.activeProfile = profileName;
115
+ await saveMainConfig(mainConfig);
116
+ }
117
+
118
+ /**
119
+ * Load a specific profile by name
120
+ */
121
+ export async function loadProfile(profileName) {
122
+ const profilePath = getProfilePath(profileName);
123
+ try {
124
+ const content = await fs.readFile(profilePath, 'utf-8');
125
+ return JSON.parse(content);
126
+ } catch (error) {
127
+ if (error.code === 'ENOENT') {
128
+ return null;
129
+ }
130
+ throw new Error(`Failed to read profile '${profileName}': ${error.message}`);
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Save a profile
136
+ */
137
+ export async function saveProfile(profileName, profileData) {
138
+ await ensureProfilesDir();
139
+ const profilePath = getProfilePath(profileName);
140
+
141
+ // Ensure name is set
142
+ profileData.name = profileName;
143
+
144
+ await fs.writeFile(profilePath, JSON.stringify(profileData, null, 2) + '\n', 'utf-8');
145
+ }
146
+
147
+ /**
148
+ * Delete a profile
149
+ */
150
+ export async function deleteProfile(profileName) {
151
+ const profilePath = getProfilePath(profileName);
152
+ try {
153
+ await fs.unlink(profilePath);
154
+ return true;
155
+ } catch (error) {
156
+ if (error.code === 'ENOENT') {
157
+ return false;
158
+ }
159
+ throw error;
160
+ }
161
+ }
162
+
163
+ /**
164
+ * List all available profiles
165
+ */
166
+ export async function listProfiles() {
167
+ const profilesDir = getProfilesDir();
168
+ try {
169
+ const files = await fs.readdir(profilesDir);
170
+ return files
171
+ .filter(f => f.endsWith('.json'))
172
+ .map(f => f.replace('.json', ''));
173
+ } catch (error) {
174
+ if (error.code === 'ENOENT') {
175
+ return [];
176
+ }
177
+ throw error;
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Check if a profile exists
183
+ */
184
+ export async function profileExists(profileName) {
185
+ const profilePath = getProfilePath(profileName);
186
+ try {
187
+ await fs.access(profilePath);
188
+ return true;
189
+ } catch {
190
+ return false;
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Get the effective configuration by merging:
196
+ * 1. Environment variables (highest priority)
197
+ * 2. CLI --profile flag (if provided)
198
+ * 3. Active profile
199
+ * 4. Legacy config.json values (backwards compatibility)
200
+ * 5. Defaults (lowest priority)
201
+ *
202
+ * @param {string|null} cliProfileOverride - Profile name from --profile flag
203
+ */
204
+ export async function getEffectiveConfig(cliProfileOverride = null) {
205
+ const config = {
206
+ server: 'http://localhost:3000',
207
+ apiKey: null,
208
+ default_environment: null,
209
+ coder_setup_path: null,
210
+ server_port: 3000,
211
+ profound_coder_path: null
212
+ };
213
+
214
+ // Load legacy/main config (lowest priority after defaults)
215
+ const mainConfig = await loadMainConfig();
216
+ if (mainConfig) {
217
+ for (const key of PROFILE_CONFIG_KEYS) {
218
+ if (mainConfig[key] !== undefined) {
219
+ config[key] = mainConfig[key];
220
+ }
221
+ }
222
+ }
223
+
224
+ // Load active profile (overrides legacy config)
225
+ const profileName = cliProfileOverride || await getActiveProfileName();
226
+ if (profileName) {
227
+ const profile = await loadProfile(profileName);
228
+ if (profile) {
229
+ for (const key of PROFILE_CONFIG_KEYS) {
230
+ if (profile[key] !== undefined) {
231
+ config[key] = profile[key];
232
+ }
233
+ }
234
+ }
235
+ }
236
+
237
+ // Apply environment variables (highest priority)
238
+ if (process.env.CODER_SERVER_URL) {
239
+ config.server = process.env.CODER_SERVER_URL;
240
+ }
241
+ if (process.env.CODER_API_KEY) {
242
+ config.apiKey = process.env.CODER_API_KEY;
243
+ }
244
+ if (process.env.CODER_SETUP_PATH) {
245
+ config.coder_setup_path = process.env.CODER_SETUP_PATH;
246
+ }
247
+ if (process.env.PORT) {
248
+ config.server_port = parseInt(process.env.PORT, 10);
249
+ }
250
+ if (process.env.PROFOUND_CODER_PATH) {
251
+ config.profound_coder_path = process.env.PROFOUND_CODER_PATH;
252
+ }
253
+
254
+ // Sanitize server URL
255
+ if (config.server && config.server.endsWith('/')) {
256
+ config.server = config.server.slice(0, -1);
257
+ }
258
+
259
+ return config;
260
+ }
261
+
262
+ /**
263
+ * Create a profile from the current legacy config (migration helper)
264
+ */
265
+ export async function createProfileFromLegacyConfig(profileName) {
266
+ const mainConfig = await loadMainConfig();
267
+ if (!mainConfig) {
268
+ return null;
269
+ }
270
+
271
+ const profileData = { name: profileName };
272
+
273
+ for (const key of PROFILE_CONFIG_KEYS) {
274
+ if (mainConfig[key] !== undefined) {
275
+ profileData[key] = mainConfig[key];
276
+ }
277
+ }
278
+
279
+ await saveProfile(profileName, profileData);
280
+ return profileData;
281
+ }
282
+
283
+ /**
284
+ * Validate profile name
285
+ */
286
+ export function isValidProfileName(name) {
287
+ // Allow alphanumeric, hyphens, underscores
288
+ return /^[a-zA-Z0-9_-]+$/.test(name);
289
+ }
290
+
291
+ /**
292
+ * Get profile keys that can be configured
293
+ */
294
+ export function getProfileConfigKeys() {
295
+ return [...PROFILE_CONFIG_KEYS];
296
+ }