@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/LICENSE.txt +322 -0
- package/README.md +102 -0
- package/coder.js +202 -0
- package/lib/commands/apply.js +238 -0
- package/lib/commands/attach.js +143 -0
- package/lib/commands/config.js +226 -0
- package/lib/commands/containers.js +213 -0
- package/lib/commands/discard.js +167 -0
- package/lib/commands/interactive.js +292 -0
- package/lib/commands/jira.js +464 -0
- package/lib/commands/license.js +172 -0
- package/lib/commands/list.js +104 -0
- package/lib/commands/login.js +329 -0
- package/lib/commands/logs.js +66 -0
- package/lib/commands/profile.js +539 -0
- package/lib/commands/reject.js +53 -0
- package/lib/commands/results.js +89 -0
- package/lib/commands/run.js +237 -0
- package/lib/commands/server.js +537 -0
- package/lib/commands/status.js +39 -0
- package/lib/commands/test.js +335 -0
- package/lib/config.js +378 -0
- package/lib/help.js +444 -0
- package/lib/http-client.js +180 -0
- package/lib/oidc.js +126 -0
- package/lib/profile.js +296 -0
- package/lib/state-capture.js +336 -0
- package/lib/task-grouping.js +210 -0
- package/lib/terminal-client.js +162 -0
- package/package.json +35 -0
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
|
+
}
|