@4ge/cli 0.1.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/LICENSE +21 -0
- package/README.md +228 -0
- package/bin/dev.js +3 -0
- package/bin/run.js +3 -0
- package/dist/base.js +398 -0
- package/dist/commands/auth/login.js +197 -0
- package/dist/commands/auth/logout.js +31 -0
- package/dist/commands/auth/profile/delete.js +34 -0
- package/dist/commands/auth/profile/list.js +32 -0
- package/dist/commands/auth/profile/use.js +30 -0
- package/dist/commands/auth/status.js +107 -0
- package/dist/commands/config/get.js +37 -0
- package/dist/commands/config/index.js +9 -0
- package/dist/commands/config/manage.js +165 -0
- package/dist/commands/config/set.js +42 -0
- package/dist/commands/config/show.js +46 -0
- package/dist/commands/epic/index.js +10 -0
- package/dist/commands/epic/list.js +91 -0
- package/dist/commands/epic/pull.js +137 -0
- package/dist/commands/epic/show.js +67 -0
- package/dist/commands/epic/update.js +104 -0
- package/dist/commands/feature/index.js +2 -0
- package/dist/commands/feature/list.js +93 -0
- package/dist/commands/feature/pull.js +122 -0
- package/dist/commands/feature/show.js +60 -0
- package/dist/commands/feature/update.js +103 -0
- package/dist/commands/idea/create.js +88 -0
- package/dist/commands/idea/graduate.js +89 -0
- package/dist/commands/idea/index.js +5 -0
- package/dist/commands/idea/list.js +83 -0
- package/dist/commands/idea/show.js +86 -0
- package/dist/commands/idea/update.js +108 -0
- package/dist/commands/init.js +354 -0
- package/dist/commands/login.js +5 -0
- package/dist/commands/plan/index.js +10 -0
- package/dist/commands/plan/pull.js +104 -0
- package/dist/commands/plan/versions.js +64 -0
- package/dist/commands/project/create.js +84 -0
- package/dist/commands/project/index.js +9 -0
- package/dist/commands/project/info.js +74 -0
- package/dist/commands/project/list.js +78 -0
- package/dist/commands/project/pull.js +127 -0
- package/dist/commands/project/update.js +104 -0
- package/dist/commands/root.js +13 -0
- package/dist/commands/skill/index.js +8 -0
- package/dist/commands/skill/install.js +71 -0
- package/dist/commands/story/index.js +10 -0
- package/dist/commands/story/list.js +81 -0
- package/dist/commands/story/pull.js +125 -0
- package/dist/commands/story/show.js +57 -0
- package/dist/commands/story/update.js +103 -0
- package/dist/commands/template/create.js +72 -0
- package/dist/commands/template/delete.js +62 -0
- package/dist/commands/template/feature/create.js +70 -0
- package/dist/commands/template/feature/delete.js +75 -0
- package/dist/commands/template/feature/list.js +69 -0
- package/dist/commands/template/feature/update.js +82 -0
- package/dist/commands/template/info.js +52 -0
- package/dist/commands/template/list.js +76 -0
- package/dist/commands/template/pull.js +99 -0
- package/dist/commands/template/update.js +71 -0
- package/dist/commands/whoami.js +5 -0
- package/dist/core/api/action-hints.js +17 -0
- package/dist/core/api/index.js +3 -0
- package/dist/core/api/retry.js +95 -0
- package/dist/core/api/validate-response.js +41 -0
- package/dist/core/auth/api-client.js +156 -0
- package/dist/core/auth/config.js +3 -0
- package/dist/core/auth/credentials.js +143 -0
- package/dist/core/auth/email-auth.js +30 -0
- package/dist/core/auth/errors.js +20 -0
- package/dist/core/auth/index.js +6 -0
- package/dist/core/auth/realtime.js +82 -0
- package/dist/core/auth/resolution.js +63 -0
- package/dist/core/auth/state.js +9 -0
- package/dist/core/auth/token-refresh.js +100 -0
- package/dist/core/config/defaults.js +31 -0
- package/dist/core/config/index.js +3 -0
- package/dist/core/config/parser.js +213 -0
- package/dist/core/config/schema.js +29 -0
- package/dist/core/project/markdown.js +37 -0
- package/dist/core/skill/installer.js +59 -0
- package/dist/core/skill/paths.js +52 -0
- package/dist/generated/api/client/client.gen.js +217 -0
- package/dist/generated/api/client/index.js +6 -0
- package/dist/generated/api/client/types.gen.js +2 -0
- package/dist/generated/api/client/utils.gen.js +231 -0
- package/dist/generated/api/client.gen.js +3 -0
- package/dist/generated/api/core/auth.gen.js +14 -0
- package/dist/generated/api/core/bodySerializer.gen.js +57 -0
- package/dist/generated/api/core/params.gen.js +103 -0
- package/dist/generated/api/core/pathSerializer.gen.js +114 -0
- package/dist/generated/api/core/queryKeySerializer.gen.js +99 -0
- package/dist/generated/api/core/serverSentEvents.gen.js +136 -0
- package/dist/generated/api/core/types.gen.js +2 -0
- package/dist/generated/api/core/utils.gen.js +87 -0
- package/dist/generated/api/index.js +2 -0
- package/dist/generated/api/sdk.gen.js +556 -0
- package/dist/generated/api/types.gen.js +2 -0
- package/dist/generated/api/zod.gen.js +1262 -0
- package/dist/ui/components/action-hints.js +33 -0
- package/dist/ui/components/cli-header.js +91 -0
- package/dist/ui/components/confirm-prompt.js +28 -0
- package/dist/ui/components/data-table.js +35 -0
- package/dist/ui/components/detail-view.js +5 -0
- package/dist/ui/components/error-display.js +5 -0
- package/dist/ui/components/prompt-layout.js +6 -0
- package/dist/ui/components/select-prompt.js +28 -0
- package/dist/ui/components/spinner.js +14 -0
- package/dist/ui/components/success-message.js +5 -0
- package/dist/ui/components/text-input.js +29 -0
- package/dist/ui/hooks/use-action-hints.js +21 -0
- package/dist/ui/hooks/use-status.js +22 -0
- package/dist/ui/index.js +15 -0
- package/dist/ui/prompt-helpers.js +55 -0
- package/dist/ui/render.js +9 -0
- package/package.json +89 -0
- package/skills/4ge-cli.md +113 -0
- package/usage.md +320 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { client } from '../../generated/api/client.gen.js';
|
|
2
|
+
import { getApiBaseUrl } from './config.js';
|
|
3
|
+
import { ensureFreshToken } from './token-refresh.js';
|
|
4
|
+
import { ConnectivityError } from './errors.js';
|
|
5
|
+
export { ConnectivityError } from './errors.js';
|
|
6
|
+
/**
|
|
7
|
+
* Check if an error is an authentication-related error.
|
|
8
|
+
* Works with error objects returned by the API client when throwOnError is false.
|
|
9
|
+
*/
|
|
10
|
+
export function isAuthError(error) {
|
|
11
|
+
return error?.code === 'TOKEN_EXPIRED' || error?.code === 'INVALID_API_KEY';
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Get actionable guidance for an authentication error.
|
|
15
|
+
* Returns null if the error is not an auth error.
|
|
16
|
+
*/
|
|
17
|
+
export function getAuthErrorHint(error) {
|
|
18
|
+
if (error?.code === 'TOKEN_EXPIRED') {
|
|
19
|
+
return "Run '4ge auth login' to re-authenticate";
|
|
20
|
+
}
|
|
21
|
+
if (error?.code === 'INVALID_API_KEY') {
|
|
22
|
+
return 'Check your 4GE_API_KEY environment variable';
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Check if the API server is reachable.
|
|
28
|
+
* Performs a lightweight HEAD request with a 5-second timeout.
|
|
29
|
+
*/
|
|
30
|
+
export async function checkConnectivity(baseUrl) {
|
|
31
|
+
const controller = new AbortController();
|
|
32
|
+
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
|
33
|
+
try {
|
|
34
|
+
await fetch(baseUrl, {
|
|
35
|
+
method: 'HEAD',
|
|
36
|
+
signal: controller.signal,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
let message = `Cannot connect to the API server at ${baseUrl}. Please check that the server is running and accessible.`;
|
|
41
|
+
if (error.name === 'AbortError') {
|
|
42
|
+
message = `Connection to API server at ${baseUrl} timed out after 5 seconds.`;
|
|
43
|
+
}
|
|
44
|
+
throw new ConnectivityError(baseUrl, message);
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
clearTimeout(timeoutId);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Create an authenticated API client wrapper.
|
|
52
|
+
* This wraps the generated API client from src/generated/api/
|
|
53
|
+
* and injects authentication headers automatically.
|
|
54
|
+
*
|
|
55
|
+
* Headers added:
|
|
56
|
+
* - Authorization: Bearer <api_key> (PAT or OAuth access token)
|
|
57
|
+
* - X-Client-Type: 4ge-agent (when isJsonMode is true)
|
|
58
|
+
*
|
|
59
|
+
* Interceptors added:
|
|
60
|
+
* - Request: injects Authorization + X-Client-Type headers
|
|
61
|
+
* - Response: intercepts 401s and attempts token refresh + retry (once)
|
|
62
|
+
* - Error: attaches structured auth error info (code + hint) for 401s
|
|
63
|
+
*
|
|
64
|
+
* NOTE: This implementation currently uses a singleton client.
|
|
65
|
+
* Interceptors are cleared and re-added to ensure the correct credentials
|
|
66
|
+
* are used for the current call. This is safe for CLI usage patterns.
|
|
67
|
+
*/
|
|
68
|
+
export function createApiClient(opts) {
|
|
69
|
+
const { credential, isJsonMode, baseUrl = getApiBaseUrl() } = opts;
|
|
70
|
+
client.setConfig({
|
|
71
|
+
baseUrl,
|
|
72
|
+
});
|
|
73
|
+
// Clear existing interceptors to avoid duplication if called multiple times
|
|
74
|
+
client.interceptors.request.clear();
|
|
75
|
+
client.interceptors.response.clear();
|
|
76
|
+
client.interceptors.error.clear();
|
|
77
|
+
client.interceptors.request.use((request) => {
|
|
78
|
+
request.headers.set('Authorization', `Bearer ${credential.api_key}`);
|
|
79
|
+
if (isJsonMode) {
|
|
80
|
+
request.headers.set('X-Client-Type', '4ge-agent');
|
|
81
|
+
}
|
|
82
|
+
return request;
|
|
83
|
+
});
|
|
84
|
+
// Response interceptor: handle 401 with token refresh + retry (once)
|
|
85
|
+
client.interceptors.response.use(async (response, request, options) => {
|
|
86
|
+
if (response.status !== 401) {
|
|
87
|
+
return response;
|
|
88
|
+
}
|
|
89
|
+
// Already retried once — don't retry again to prevent infinite loops
|
|
90
|
+
if (request.headers.has('X-Auth-Retry')) {
|
|
91
|
+
return response;
|
|
92
|
+
}
|
|
93
|
+
// PAT auth (environment source) cannot be refreshed
|
|
94
|
+
if (credential.source === 'environment') {
|
|
95
|
+
return response;
|
|
96
|
+
}
|
|
97
|
+
// No refresh token available — can't refresh
|
|
98
|
+
if (!credential.refresh_token) {
|
|
99
|
+
return response;
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
const freshCredential = await ensureFreshToken();
|
|
103
|
+
// Only retry if refresh produced a new access token
|
|
104
|
+
if (freshCredential && freshCredential.api_key !== credential.api_key) {
|
|
105
|
+
const retryHeaders = new Headers(request.headers);
|
|
106
|
+
retryHeaders.set('Authorization', `Bearer ${freshCredential.api_key}`);
|
|
107
|
+
retryHeaders.set('X-Auth-Retry', '1');
|
|
108
|
+
const _fetch = options.fetch ?? globalThis.fetch;
|
|
109
|
+
const retryBody = options.serializedBody || undefined;
|
|
110
|
+
const retryRequest = new Request(request.url, {
|
|
111
|
+
method: request.method,
|
|
112
|
+
headers: retryHeaders,
|
|
113
|
+
body: retryBody,
|
|
114
|
+
redirect: request.redirect,
|
|
115
|
+
credentials: request.credentials,
|
|
116
|
+
});
|
|
117
|
+
return await _fetch(retryRequest);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
// Refresh failed. If the server is unreachable, ensureFreshToken throws
|
|
122
|
+
// a ConnectivityError — re-throw it so callers can distinguish a dead
|
|
123
|
+
// server (CONNECTION_ERROR) from a dead token (TOKEN_EXPIRED). The
|
|
124
|
+
// error interceptor preserves it instead of forcing TOKEN_EXPIRED.
|
|
125
|
+
if (err instanceof ConnectivityError) {
|
|
126
|
+
throw err;
|
|
127
|
+
}
|
|
128
|
+
// Other refresh failures (rejected refresh token) fall through to the
|
|
129
|
+
// original 401, which the error interceptor labels TOKEN_EXPIRED.
|
|
130
|
+
}
|
|
131
|
+
return response;
|
|
132
|
+
});
|
|
133
|
+
// Error interceptor: attach structured auth error info for 401s
|
|
134
|
+
client.interceptors.error.use((error, response, _request, _options) => {
|
|
135
|
+
// A ConnectivityError re-thrown from the 401-retry path means the server
|
|
136
|
+
// is unreachable — preserve its code so callers don't mistake it for a
|
|
137
|
+
// session-expiry (the 401 response is still attached, which would
|
|
138
|
+
// otherwise force TOKEN_EXPIRED below).
|
|
139
|
+
if (error instanceof ConnectivityError) {
|
|
140
|
+
return error;
|
|
141
|
+
}
|
|
142
|
+
if (response?.status === 401) {
|
|
143
|
+
const isPatAuth = credential.source === 'environment' || !credential.refresh_token;
|
|
144
|
+
const authError = {
|
|
145
|
+
...(typeof error === 'object' && error !== null ? error : { message: String(error) }),
|
|
146
|
+
code: isPatAuth ? 'INVALID_API_KEY' : 'TOKEN_EXPIRED',
|
|
147
|
+
hint: isPatAuth
|
|
148
|
+
? 'Check your 4GE_API_KEY environment variable'
|
|
149
|
+
: "Run '4ge auth login' to re-authenticate",
|
|
150
|
+
};
|
|
151
|
+
return authError;
|
|
152
|
+
}
|
|
153
|
+
return error;
|
|
154
|
+
});
|
|
155
|
+
return client;
|
|
156
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
// Zod schema for credentials file
|
|
6
|
+
const ProfileCredentialSchema = z.object({
|
|
7
|
+
// PAT authentication
|
|
8
|
+
api_key: z.string().min(1).optional(),
|
|
9
|
+
// OAuth / email authentication
|
|
10
|
+
access_token: z.string().min(1).optional(),
|
|
11
|
+
refresh_token: z.string().min(1).optional(),
|
|
12
|
+
expires_at: z.number().optional(),
|
|
13
|
+
user: z.object({
|
|
14
|
+
id: z.string(),
|
|
15
|
+
email: z.string(),
|
|
16
|
+
}).optional(),
|
|
17
|
+
}).refine((data) => data.api_key || data.access_token, { message: 'Profile must have either api_key or access_token' });
|
|
18
|
+
export const CredentialsFileSchema = z.object({
|
|
19
|
+
default: ProfileCredentialSchema.optional(),
|
|
20
|
+
profiles: z.record(z.string(), ProfileCredentialSchema).default({}),
|
|
21
|
+
active_profile: z.string().default('default'),
|
|
22
|
+
});
|
|
23
|
+
// Paths
|
|
24
|
+
export function getGlobalCredentialsPath() {
|
|
25
|
+
return join(homedir(), '.4ge', 'credentials.json');
|
|
26
|
+
}
|
|
27
|
+
export function getLocalCredentialsPath() {
|
|
28
|
+
return join(process.cwd(), '.4ge', 'credentials.json');
|
|
29
|
+
}
|
|
30
|
+
// Read credentials file (returns null if doesn't exist or invalid)
|
|
31
|
+
export async function readCredentialsFile(filePath) {
|
|
32
|
+
try {
|
|
33
|
+
const content = await readFile(filePath, 'utf-8');
|
|
34
|
+
const result = CredentialsFileSchema.safeParse(JSON.parse(content));
|
|
35
|
+
return result.success ? result.data : null;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// Write credentials file (creates directory if needed)
|
|
42
|
+
export async function writeCredentialsFile(filePath, data) {
|
|
43
|
+
const dir = dirname(filePath);
|
|
44
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
45
|
+
await writeFile(filePath, JSON.stringify(data, null, 2), { encoding: 'utf-8', mode: 0o600 });
|
|
46
|
+
}
|
|
47
|
+
// Save a credential to a specific location and profile
|
|
48
|
+
export async function saveCredential(opts) {
|
|
49
|
+
const hasToken = opts.apiKey || opts.accessToken;
|
|
50
|
+
if (!hasToken) {
|
|
51
|
+
throw new Error('Either apiKey or accessToken must be provided');
|
|
52
|
+
}
|
|
53
|
+
const filePath = opts.local ? getLocalCredentialsPath() : getGlobalCredentialsPath();
|
|
54
|
+
const existing = (await readCredentialsFile(filePath)) ?? {
|
|
55
|
+
profiles: {},
|
|
56
|
+
active_profile: 'default',
|
|
57
|
+
};
|
|
58
|
+
const profile = opts.profile ?? 'default';
|
|
59
|
+
const profileData = {};
|
|
60
|
+
if (opts.apiKey)
|
|
61
|
+
profileData.api_key = opts.apiKey;
|
|
62
|
+
if (opts.accessToken)
|
|
63
|
+
profileData.access_token = opts.accessToken;
|
|
64
|
+
if (opts.refreshToken)
|
|
65
|
+
profileData.refresh_token = opts.refreshToken;
|
|
66
|
+
if (opts.expiresAt !== undefined)
|
|
67
|
+
profileData.expires_at = opts.expiresAt;
|
|
68
|
+
if (opts.user)
|
|
69
|
+
profileData.user = opts.user;
|
|
70
|
+
if (profile === 'default') {
|
|
71
|
+
existing.default = profileData;
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
existing.profiles[profile] = profileData;
|
|
75
|
+
}
|
|
76
|
+
await writeCredentialsFile(filePath, existing);
|
|
77
|
+
}
|
|
78
|
+
// Clear a credential from a specific location
|
|
79
|
+
export async function clearCredential(opts) {
|
|
80
|
+
const filePath = opts.local ? getLocalCredentialsPath() : getGlobalCredentialsPath();
|
|
81
|
+
const existing = await readCredentialsFile(filePath);
|
|
82
|
+
if (!existing)
|
|
83
|
+
return;
|
|
84
|
+
const profileToClear = opts.profile ?? existing.active_profile;
|
|
85
|
+
if (profileToClear === 'default') {
|
|
86
|
+
delete existing.default;
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
delete existing.profiles[profileToClear];
|
|
90
|
+
}
|
|
91
|
+
if (existing.active_profile === profileToClear) {
|
|
92
|
+
existing.active_profile = 'default';
|
|
93
|
+
}
|
|
94
|
+
await writeCredentialsFile(filePath, existing);
|
|
95
|
+
}
|
|
96
|
+
// List all profiles in the global credentials file
|
|
97
|
+
export async function listProfiles() {
|
|
98
|
+
const filePath = getGlobalCredentialsPath();
|
|
99
|
+
const data = (await readCredentialsFile(filePath)) ?? {
|
|
100
|
+
profiles: {},
|
|
101
|
+
active_profile: 'default',
|
|
102
|
+
};
|
|
103
|
+
const profiles = [];
|
|
104
|
+
if (data.default?.api_key || data.default?.access_token) {
|
|
105
|
+
profiles.push({ name: 'default', is_active: data.active_profile === 'default' });
|
|
106
|
+
}
|
|
107
|
+
profiles.push(...Object.keys(data.profiles).map((name) => ({
|
|
108
|
+
name,
|
|
109
|
+
is_active: data.active_profile === name,
|
|
110
|
+
})));
|
|
111
|
+
return {
|
|
112
|
+
profiles,
|
|
113
|
+
active_profile: data.active_profile,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
// Set the active profile in global credentials
|
|
117
|
+
export async function setActiveProfile(name) {
|
|
118
|
+
const filePath = getGlobalCredentialsPath();
|
|
119
|
+
const existing = await readCredentialsFile(filePath);
|
|
120
|
+
if (!existing) {
|
|
121
|
+
throw new Error('No credentials file found. Please login first.');
|
|
122
|
+
}
|
|
123
|
+
if (name !== 'default' && !existing.profiles[name]) {
|
|
124
|
+
throw new Error(`Profile "${name}" not found.`);
|
|
125
|
+
}
|
|
126
|
+
existing.active_profile = name;
|
|
127
|
+
await writeCredentialsFile(filePath, existing);
|
|
128
|
+
}
|
|
129
|
+
// Delete a named profile from global credentials
|
|
130
|
+
export async function deleteProfile(name) {
|
|
131
|
+
if (name === 'default') {
|
|
132
|
+
throw new Error('Cannot delete the default profile.');
|
|
133
|
+
}
|
|
134
|
+
const filePath = getGlobalCredentialsPath();
|
|
135
|
+
const existing = await readCredentialsFile(filePath);
|
|
136
|
+
if (!existing)
|
|
137
|
+
return;
|
|
138
|
+
delete existing.profiles[name];
|
|
139
|
+
if (existing.active_profile === name) {
|
|
140
|
+
existing.active_profile = 'default';
|
|
141
|
+
}
|
|
142
|
+
await writeCredentialsFile(filePath, existing);
|
|
143
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { cliAuthEmailLogin } from '../../generated/api/sdk.gen.js';
|
|
2
|
+
/**
|
|
3
|
+
* Authenticate with email and password via the Core API.
|
|
4
|
+
* Uses the generated SDK function cliAuthEmailLogin.
|
|
5
|
+
*/
|
|
6
|
+
export async function authenticateWithEmail(opts) {
|
|
7
|
+
const { email, password, baseUrl } = opts;
|
|
8
|
+
// Build the client options - we don't need auth for this endpoint (security: [])
|
|
9
|
+
const clientOptions = {};
|
|
10
|
+
if (baseUrl) {
|
|
11
|
+
// We need to set the baseUrl on the client before calling
|
|
12
|
+
// This will be handled by the caller setting up the client
|
|
13
|
+
}
|
|
14
|
+
const { data, error } = await cliAuthEmailLogin({
|
|
15
|
+
body: { email, password },
|
|
16
|
+
...(baseUrl ? { baseUrl } : {}),
|
|
17
|
+
});
|
|
18
|
+
if (error) {
|
|
19
|
+
const errorMsg = error instanceof Error ? error.message
|
|
20
|
+
: (typeof error === 'object' && error !== null && 'message' in error) ? String(error.message)
|
|
21
|
+
: String(error);
|
|
22
|
+
throw new Error(`Email authentication failed: ${errorMsg}`);
|
|
23
|
+
}
|
|
24
|
+
// Extract the inner data payload from the SDK envelope
|
|
25
|
+
const payload = data?.data;
|
|
26
|
+
if (!payload?.access_token) {
|
|
27
|
+
throw new Error('Email authentication failed: invalid response from server');
|
|
28
|
+
}
|
|
29
|
+
return payload;
|
|
30
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ConnectivityError — raised when the 4ge Core API server is unreachable
|
|
3
|
+
* (network failure, DNS, timeout, abort, or non-JSON response).
|
|
4
|
+
*
|
|
5
|
+
* Kept in its own module so that low-level auth helpers (e.g. token-refresh)
|
|
6
|
+
* can throw ConnectivityError without importing the full api-client (which
|
|
7
|
+
* would create a circular dependency: api-client imports ensureFreshToken
|
|
8
|
+
* from token-refresh).
|
|
9
|
+
*
|
|
10
|
+
* `code` is always 'CONNECTION_ERROR' for machine-readable consumers.
|
|
11
|
+
*/
|
|
12
|
+
export class ConnectivityError extends Error {
|
|
13
|
+
url;
|
|
14
|
+
code = 'CONNECTION_ERROR';
|
|
15
|
+
constructor(url, message) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.url = url;
|
|
18
|
+
this.name = 'ConnectivityError';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { createClient } from '@supabase/supabase-js';
|
|
2
|
+
/**
|
|
3
|
+
* Subscribe to a Supabase Realtime broadcast channel to receive OAuth tokens.
|
|
4
|
+
* Channel pattern: oauth:${state}
|
|
5
|
+
* Event: 'tokens'
|
|
6
|
+
*
|
|
7
|
+
* Returns a promise that resolves with the tokens when the broadcast event is received,
|
|
8
|
+
* or rejects on timeout.
|
|
9
|
+
*/
|
|
10
|
+
export async function subscribeForTokens(opts) {
|
|
11
|
+
const { supabaseUrl, supabaseAnonKey, state, timeoutMs = 5 * 60 * 1000 } = opts;
|
|
12
|
+
const supabase = createClient(supabaseUrl, supabaseAnonKey);
|
|
13
|
+
const channel = supabase.channel(`oauth:${state}`);
|
|
14
|
+
let timeoutHandle = null;
|
|
15
|
+
let resolveFunc = null;
|
|
16
|
+
let rejectFunc = null;
|
|
17
|
+
const tokensPromise = new Promise((resolve, reject) => {
|
|
18
|
+
resolveFunc = resolve;
|
|
19
|
+
rejectFunc = reject;
|
|
20
|
+
// Set timeout for handshake
|
|
21
|
+
timeoutHandle = setTimeout(() => {
|
|
22
|
+
cleanup();
|
|
23
|
+
reject(new Error('OAuth handshake timed out after 5 minutes. Please try again.'));
|
|
24
|
+
}, timeoutMs);
|
|
25
|
+
});
|
|
26
|
+
const cleanup = () => {
|
|
27
|
+
if (timeoutHandle) {
|
|
28
|
+
clearTimeout(timeoutHandle);
|
|
29
|
+
timeoutHandle = null;
|
|
30
|
+
}
|
|
31
|
+
supabase.removeChannel(channel);
|
|
32
|
+
};
|
|
33
|
+
// Subscribe to the broadcast channel and listen for 'tokens' event
|
|
34
|
+
channel.on('broadcast', { event: 'tokens' }, (payload) => {
|
|
35
|
+
const tokens = payload.payload;
|
|
36
|
+
// Validate the tokens have all required fields (securely receive)
|
|
37
|
+
if (!tokens?.access_token ||
|
|
38
|
+
!tokens?.refresh_token ||
|
|
39
|
+
typeof tokens?.expires_at !== 'number' ||
|
|
40
|
+
typeof tokens?.expires_in !== 'number' ||
|
|
41
|
+
!tokens?.user?.id ||
|
|
42
|
+
!tokens?.user?.email) {
|
|
43
|
+
return; // Ignore invalid payloads
|
|
44
|
+
}
|
|
45
|
+
if (timeoutHandle) {
|
|
46
|
+
clearTimeout(timeoutHandle);
|
|
47
|
+
timeoutHandle = null;
|
|
48
|
+
}
|
|
49
|
+
resolveFunc(tokens);
|
|
50
|
+
});
|
|
51
|
+
// Subscribe and wait for connection
|
|
52
|
+
await new Promise((resolve, reject) => {
|
|
53
|
+
channel.subscribe((status, err) => {
|
|
54
|
+
if (status === 'SUBSCRIBED') {
|
|
55
|
+
resolve();
|
|
56
|
+
}
|
|
57
|
+
else if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') {
|
|
58
|
+
cleanup();
|
|
59
|
+
reject(new Error(`Realtime subscription failed: ${status}${err ? ` - ${err.message}` : ''}`));
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
return {
|
|
64
|
+
tokens: await tokensPromise,
|
|
65
|
+
cleanup,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Get the Supabase URL and anon key from environment variables.
|
|
70
|
+
* Throws if either is missing.
|
|
71
|
+
*/
|
|
72
|
+
export function getSupabaseConfig() {
|
|
73
|
+
const supabaseUrl = process.env.SUPABASE_URL;
|
|
74
|
+
const supabaseAnonKey = process.env.SUPABASE_ANON_KEY;
|
|
75
|
+
if (!supabaseUrl) {
|
|
76
|
+
throw new Error('SUPABASE_URL environment variable is required for OAuth login. Set it in your .env file or environment.');
|
|
77
|
+
}
|
|
78
|
+
if (!supabaseAnonKey) {
|
|
79
|
+
throw new Error('SUPABASE_ANON_KEY environment variable is required for OAuth login. Set it in your .env file or environment.');
|
|
80
|
+
}
|
|
81
|
+
return { supabaseUrl, supabaseAnonKey };
|
|
82
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { getGlobalCredentialsPath, getLocalCredentialsPath, readCredentialsFile } from './credentials.js';
|
|
2
|
+
/**
|
|
3
|
+
* Resolve credentials in priority order:
|
|
4
|
+
* 1. 4GE_API_KEY environment variable → { source: 'environment' }
|
|
5
|
+
* 2. ./.4ge/credentials.json (local) → { source: 'local' }
|
|
6
|
+
* 3. ~/.4ge/credentials.json active profile → { source: 'global', profile: '...' }
|
|
7
|
+
*
|
|
8
|
+
* Returns null if no credentials found.
|
|
9
|
+
*/
|
|
10
|
+
export async function resolveCredential() {
|
|
11
|
+
// 1. Environment variable
|
|
12
|
+
const envKey = process.env['4GE_API_KEY'];
|
|
13
|
+
if (envKey && envKey.length > 0) {
|
|
14
|
+
return {
|
|
15
|
+
api_key: envKey,
|
|
16
|
+
source: 'environment',
|
|
17
|
+
profile: 'default',
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
// 2. Local credentials
|
|
21
|
+
const localData = await readCredentialsFile(getLocalCredentialsPath());
|
|
22
|
+
if (localData) {
|
|
23
|
+
const { active_profile } = localData;
|
|
24
|
+
const profile = active_profile === 'default'
|
|
25
|
+
? localData.default
|
|
26
|
+
: localData.profiles[active_profile];
|
|
27
|
+
if (profile) {
|
|
28
|
+
const api_key = profile.access_token || profile.api_key;
|
|
29
|
+
if (api_key && api_key.length > 0) {
|
|
30
|
+
return {
|
|
31
|
+
api_key,
|
|
32
|
+
source: 'local',
|
|
33
|
+
profile: active_profile,
|
|
34
|
+
refresh_token: profile.refresh_token,
|
|
35
|
+
expires_at: profile.expires_at,
|
|
36
|
+
user: profile.user,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// 3. Global credentials
|
|
42
|
+
const globalData = await readCredentialsFile(getGlobalCredentialsPath());
|
|
43
|
+
if (globalData) {
|
|
44
|
+
const { active_profile } = globalData;
|
|
45
|
+
const profile = active_profile === 'default'
|
|
46
|
+
? globalData.default
|
|
47
|
+
: globalData.profiles[active_profile];
|
|
48
|
+
if (profile) {
|
|
49
|
+
const api_key = profile.access_token || profile.api_key;
|
|
50
|
+
if (api_key && api_key.length > 0) {
|
|
51
|
+
return {
|
|
52
|
+
api_key,
|
|
53
|
+
source: 'global',
|
|
54
|
+
profile: active_profile,
|
|
55
|
+
refresh_token: profile.refresh_token,
|
|
56
|
+
expires_at: profile.expires_at,
|
|
57
|
+
user: profile.user,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
/**
|
|
3
|
+
* Generate a cryptographically secure state token for OAuth Realtime flow.
|
|
4
|
+
* Used as the channel identifier (oauth:${state}) and CSRF protection.
|
|
5
|
+
* @param bytes - Number of random bytes (default 32 = 64 hex chars)
|
|
6
|
+
*/
|
|
7
|
+
export function generateState(bytes = 32) {
|
|
8
|
+
return randomBytes(bytes).toString('hex');
|
|
9
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { resolveCredential } from './resolution.js';
|
|
2
|
+
import { saveCredential } from './credentials.js';
|
|
3
|
+
import { getApiBaseUrl } from './config.js';
|
|
4
|
+
import { ConnectivityError } from './errors.js';
|
|
5
|
+
/**
|
|
6
|
+
* Ensures the current credential has a fresh access token.
|
|
7
|
+
* If the token is expired or expiring soon, it performs a refresh.
|
|
8
|
+
*
|
|
9
|
+
* Returns the fresh credential.
|
|
10
|
+
*/
|
|
11
|
+
export async function ensureFreshToken() {
|
|
12
|
+
const credential = await resolveCredential();
|
|
13
|
+
if (!credential || (!credential.api_key && !credential.refresh_token)) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
// Environment-sourced credentials should never be refreshed or persisted
|
|
17
|
+
if (credential.source === 'environment') {
|
|
18
|
+
return credential;
|
|
19
|
+
}
|
|
20
|
+
// Skip refresh for PAT-based auth (no refresh_token)
|
|
21
|
+
if (!credential.refresh_token) {
|
|
22
|
+
return credential;
|
|
23
|
+
}
|
|
24
|
+
// Check if token is expired or expiring soon
|
|
25
|
+
const now = Math.floor(Date.now() / 1000);
|
|
26
|
+
const isFresh = credential.expires_at && credential.expires_at > (now + 30);
|
|
27
|
+
if (isFresh) {
|
|
28
|
+
return credential;
|
|
29
|
+
}
|
|
30
|
+
// Token is expired or expiring soon — refresh it.
|
|
31
|
+
//
|
|
32
|
+
// Two failure modes must be distinguished:
|
|
33
|
+
// 1. Network/connectivity failure (server unreachable, DNS, timeout):
|
|
34
|
+
// the fetch throws (TypeError) or aborts. Classify as CONNECTION_ERROR
|
|
35
|
+
// so callers can tell the user "the server is down" rather than
|
|
36
|
+
// "your session expired".
|
|
37
|
+
// 2. Session genuinely expired: the server is reachable and explicitly
|
|
38
|
+
// rejects the refresh token (401 / error envelope). Classify as
|
|
39
|
+
// SESSION_EXPIRED so the user is prompted to re-authenticate.
|
|
40
|
+
const baseUrl = getApiBaseUrl();
|
|
41
|
+
const refreshUrl = `${baseUrl}/api/cli/v1/auth/token/refresh`;
|
|
42
|
+
let response;
|
|
43
|
+
try {
|
|
44
|
+
response = await fetch(refreshUrl, {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers: { 'Content-Type': 'application/json' },
|
|
47
|
+
body: JSON.stringify({ refresh_token: credential.refresh_token }),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
catch (networkError) {
|
|
51
|
+
// fetch threw — server unreachable / DNS / timeout / abort.
|
|
52
|
+
// This is NOT a session-expiry; do not conflate them. Reuse the
|
|
53
|
+
// canonical ConnectivityError class so instanceof checks and code
|
|
54
|
+
// consumers stay consistent across the auth module.
|
|
55
|
+
throw new ConnectivityError(refreshUrl, `Cannot connect to the API server to refresh the token: ${networkError.message}. ` +
|
|
56
|
+
`Check that the server is running and reachable at ${baseUrl}.`);
|
|
57
|
+
}
|
|
58
|
+
let result;
|
|
59
|
+
try {
|
|
60
|
+
result = await response.json();
|
|
61
|
+
}
|
|
62
|
+
catch (parseError) {
|
|
63
|
+
// Server responded but body wasn't JSON — treat as a connectivity/server error,
|
|
64
|
+
// not session expiry.
|
|
65
|
+
throw new ConnectivityError(refreshUrl, `Token refresh endpoint returned a non-JSON response (status ${response.status}): ${parseError.message}.`);
|
|
66
|
+
}
|
|
67
|
+
if (result.error || !result.data) {
|
|
68
|
+
// Server is reachable and explicitly rejected the refresh token.
|
|
69
|
+
throw new Error(`Session expired. Please re-authenticate: run '4ge auth login'\n` +
|
|
70
|
+
`Details: ${result.error?.message || 'Refresh token is invalid or expired'}`);
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
// Update the credential and persist it
|
|
74
|
+
await saveCredential({
|
|
75
|
+
accessToken: result.data.access_token,
|
|
76
|
+
refreshToken: result.data.refresh_token,
|
|
77
|
+
expiresAt: result.data.expires_at,
|
|
78
|
+
user: result.data.user || credential.user,
|
|
79
|
+
profile: credential.profile,
|
|
80
|
+
local: credential.source === 'local',
|
|
81
|
+
});
|
|
82
|
+
return {
|
|
83
|
+
...credential,
|
|
84
|
+
api_key: result.data.access_token,
|
|
85
|
+
refresh_token: result.data.refresh_token,
|
|
86
|
+
expires_at: result.data.expires_at,
|
|
87
|
+
user: result.data.user || credential.user,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
// Re-throw errors we've already classified (ConnectivityError or the
|
|
92
|
+
// "Session expired" Error above) without wrapping them — their message
|
|
93
|
+
// and code must be preserved for callers to act on.
|
|
94
|
+
if (error instanceof ConnectivityError || error.message?.includes('Session expired')) {
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
// Unexpected error (e.g. saveCredential failure) — surface it.
|
|
98
|
+
throw new Error(`Failed to refresh token: ${error.message}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the roadmap query value for a hierarchy pull (plan:pull + the
|
|
3
|
+
* recursive tree path of epic/story/feature:pull).
|
|
4
|
+
*
|
|
5
|
+
* Precedence: explicit `--roadmap` flag > `config.hierarchy.default_roadmap_filter`
|
|
6
|
+
* > omit the filter entirely (pull every roadmap status).
|
|
7
|
+
*
|
|
8
|
+
* Returns the roadmap literal to send, or `undefined` to omit the filter.
|
|
9
|
+
*/
|
|
10
|
+
export function resolveRoadmapFilter(flagValue, config) {
|
|
11
|
+
if (flagValue)
|
|
12
|
+
return flagValue;
|
|
13
|
+
const configured = config?.hierarchy?.default_roadmap_filter;
|
|
14
|
+
if (configured && configured !== 'all')
|
|
15
|
+
return configured;
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Resolve whether a hierarchy pull should descend into the tree.
|
|
20
|
+
*
|
|
21
|
+
* Precedence: explicit `--recursive`/`--no-recursive` flag >
|
|
22
|
+
* `config.hierarchy.recursive_pull` > `false`.
|
|
23
|
+
*
|
|
24
|
+
* `flagValue` is `undefined` when neither `--recursive` nor `--no-recursive`
|
|
25
|
+
* was passed (oclif boolean with `allowNo` and no default).
|
|
26
|
+
*/
|
|
27
|
+
export function resolveRecursive(flagValue, config) {
|
|
28
|
+
if (flagValue !== undefined)
|
|
29
|
+
return flagValue;
|
|
30
|
+
return config?.hierarchy?.recursive_pull ?? false;
|
|
31
|
+
}
|