@agentic-kit/cli 0.8.1 → 0.9.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 +15 -2
- package/account-store.d.ts +19 -0
- package/account-store.js +59 -0
- package/api-key.d.ts +51 -0
- package/api-key.js +96 -0
- package/auth-error.d.ts +4 -0
- package/auth-error.js +114 -0
- package/auth.d.ts +28 -0
- package/auth.js +119 -0
- package/backend-store.d.ts +19 -0
- package/backend-store.js +64 -0
- package/commands.d.ts +3 -0
- package/commands.js +112 -0
- package/config.d.ts +16 -0
- package/config.js +53 -1
- package/credentials.d.ts +11 -0
- package/credentials.js +32 -0
- package/db-tools.d.ts +2 -2
- package/db-tools.js +14 -18
- package/esm/account-store.d.ts +19 -0
- package/esm/account-store.js +54 -0
- package/esm/api-key.d.ts +51 -0
- package/esm/api-key.js +88 -0
- package/esm/auth-error.d.ts +4 -0
- package/esm/auth-error.js +108 -0
- package/esm/auth.d.ts +28 -0
- package/esm/auth.js +114 -0
- package/esm/backend-store.d.ts +19 -0
- package/esm/backend-store.js +58 -0
- package/esm/commands.d.ts +3 -0
- package/esm/commands.js +109 -0
- package/esm/config.d.ts +16 -0
- package/esm/config.js +51 -0
- package/esm/credentials.d.ts +11 -0
- package/esm/credentials.js +28 -0
- package/esm/db-tools.d.ts +2 -2
- package/esm/db-tools.js +14 -18
- package/esm/index.js +24 -1
- package/index.js +23 -0
- package/package.json +7 -5
package/README.md
CHANGED
|
@@ -37,6 +37,9 @@ The merged tree is materialized into the appstash-owned agent dir, so the sessio
|
|
|
37
37
|
| `agent [options...]` | Start an interactive session with the harness skills |
|
|
38
38
|
| `agent -p "prompt"` | One-shot print mode |
|
|
39
39
|
| `agent init` | Configure the skills source (repo + pin) interactively |
|
|
40
|
+
| `agent login` | Sign in to the Constructive platform (interactive) |
|
|
41
|
+
| `agent logout` | Revoke the API key and clear the stored session |
|
|
42
|
+
| `agent whoami` | Show the signed-in account, backend, and masked API key |
|
|
40
43
|
| `agent skills list` | Resolve and list the effective skill set per layer |
|
|
41
44
|
| `agent skills update` | Re-fetch the base release and re-materialize |
|
|
42
45
|
| `agent help` | Usage |
|
|
@@ -60,12 +63,22 @@ The merged tree is materialized into the appstash-owned agent dir, so the sessio
|
|
|
60
63
|
|
|
61
64
|
Env overrides: `AGENT_SKILLS_REPO`, `AGENT_SKILLS_PIN`, `AGENT_HOME` (appstash base dir), `GITHUB_TOKEN` (private skills repos).
|
|
62
65
|
|
|
63
|
-
|
|
66
|
+
## Authentication
|
|
67
|
+
|
|
68
|
+
`agent login` signs in to the Constructive platform with email and password. The flow asks for a backend first: `localnet` (a local backend on `*.localhost:3000`), `devnet` (`*.launchql.dev`), or a custom API URL. After sign-in, the CLI mints a 5-year API key for the db tools and checks it on each startup. `agent logout` revokes the key and deletes the session.
|
|
69
|
+
|
|
70
|
+
Credential files (mode `0600`, plaintext):
|
|
71
|
+
|
|
72
|
+
- `~/.constructive/config/agent/account.json` — the signed-in session (user, access token, API key)
|
|
73
|
+
- `~/.constructive/config/agent/backend-config.json` — the selected backend endpoints
|
|
74
|
+
|
|
75
|
+
The db tools read these files on each tool call, so a login mid-session is picked up without a restart. Tokens never enter the environment of child processes.
|
|
76
|
+
|
|
77
|
+
Env overrides for CI and headless use (these win over the stored session; read per tool call):
|
|
64
78
|
`CONSTRUCTIVE_USER_ID`, `CONSTRUCTIVE_ACCESS_TOKEN`, `CONSTRUCTIVE_API_KEY` (optional), `CONSTRUCTIVE_API_ENDPOINT`, `CONSTRUCTIVE_MODULES_ENDPOINT` (default to the local backend).
|
|
65
79
|
|
|
66
80
|
## Roadmap
|
|
67
81
|
|
|
68
|
-
- `agent login` — sign in to a Constructive backend and persist credentials in appstash instead of env vars.
|
|
69
82
|
- Harness system-prompt section + templates/prompts materialization, shared with constructive-desktop via a common adapter package.
|
|
70
83
|
|
|
71
84
|
## Credits
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { ConfigStore } from 'appstash';
|
|
2
|
+
export interface AccountSession {
|
|
3
|
+
userId: string;
|
|
4
|
+
email: string;
|
|
5
|
+
accessToken: string;
|
|
6
|
+
accessTokenExpiresAt?: string;
|
|
7
|
+
apiKey?: string;
|
|
8
|
+
keyId?: string;
|
|
9
|
+
apiKeyExpiresAt?: string;
|
|
10
|
+
signedInAt: number;
|
|
11
|
+
}
|
|
12
|
+
export declare function loadSession(store: ConfigStore, context?: string): AccountSession | null;
|
|
13
|
+
/**
|
|
14
|
+
* `context` names the backend the session belongs to; it does not have to exist
|
|
15
|
+
* as a context yet, so a sign-in can be filed before the backend is committed
|
|
16
|
+
* and a failed sign-in leaves nothing behind.
|
|
17
|
+
*/
|
|
18
|
+
export declare function saveSession(store: ConfigStore, session: AccountSession, context?: string): void;
|
|
19
|
+
export declare function clearSession(store: ConfigStore, context?: string): void;
|
package/account-store.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.loadSession = loadSession;
|
|
4
|
+
exports.saveSession = saveSession;
|
|
5
|
+
exports.clearSession = clearSession;
|
|
6
|
+
const backend_store_1 = require("./backend-store");
|
|
7
|
+
/**
|
|
8
|
+
* Sessions live in the shared Constructive stash, as the credentials of the
|
|
9
|
+
* active context (the chosen backend). The store owns the file layout, the
|
|
10
|
+
* atomic 0600 writes and the at-rest encoding; this module only maps between
|
|
11
|
+
* its `ContextCredentials` and the session shape the CLI passes around.
|
|
12
|
+
*/
|
|
13
|
+
function currentContextName(store) {
|
|
14
|
+
return store.getCurrentContext()?.name ?? null;
|
|
15
|
+
}
|
|
16
|
+
/** The active context, defaulting to localnet the first time one is needed. */
|
|
17
|
+
function ensureContextName(store) {
|
|
18
|
+
return currentContextName(store) ?? (0, backend_store_1.saveBackendConfig)(store, backend_store_1.BACKEND_PRESETS.localnet);
|
|
19
|
+
}
|
|
20
|
+
function loadSession(store, context) {
|
|
21
|
+
const contextName = context ?? currentContextName(store);
|
|
22
|
+
if (!contextName)
|
|
23
|
+
return null;
|
|
24
|
+
const creds = store.getCredentials(contextName);
|
|
25
|
+
if (!creds?.token || !creds.userId)
|
|
26
|
+
return null;
|
|
27
|
+
return {
|
|
28
|
+
userId: creds.userId,
|
|
29
|
+
email: creds.email ?? '',
|
|
30
|
+
accessToken: creds.token,
|
|
31
|
+
accessTokenExpiresAt: creds.expiresAt,
|
|
32
|
+
apiKey: creds.apiKey,
|
|
33
|
+
keyId: creds.keyId,
|
|
34
|
+
apiKeyExpiresAt: creds.apiKeyExpiresAt,
|
|
35
|
+
signedInAt: creds.signedInAt ?? 0
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* `context` names the backend the session belongs to; it does not have to exist
|
|
40
|
+
* as a context yet, so a sign-in can be filed before the backend is committed
|
|
41
|
+
* and a failed sign-in leaves nothing behind.
|
|
42
|
+
*/
|
|
43
|
+
function saveSession(store, session, context) {
|
|
44
|
+
store.setCredentials(context ?? ensureContextName(store), {
|
|
45
|
+
token: session.accessToken,
|
|
46
|
+
expiresAt: session.accessTokenExpiresAt,
|
|
47
|
+
userId: session.userId,
|
|
48
|
+
email: session.email,
|
|
49
|
+
apiKey: session.apiKey,
|
|
50
|
+
keyId: session.keyId,
|
|
51
|
+
apiKeyExpiresAt: session.apiKeyExpiresAt,
|
|
52
|
+
signedInAt: session.signedInAt
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
function clearSession(store, context) {
|
|
56
|
+
const contextName = context ?? currentContextName(store);
|
|
57
|
+
if (contextName)
|
|
58
|
+
store.removeCredentials(contextName);
|
|
59
|
+
}
|
package/api-key.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export declare const API_KEY_NAME = "agentic-cli";
|
|
2
|
+
export declare const API_KEY_ACCESS_LEVEL = "full_access";
|
|
3
|
+
export declare const API_KEY_MFA_LEVEL = "none";
|
|
4
|
+
export declare const API_KEY_YEARS = 5;
|
|
5
|
+
/**
|
|
6
|
+
* Re-mint once the key is within a week of expiry. The mint needs a
|
|
7
|
+
* fresh-password step-up window, so in practice the refresh succeeds only right
|
|
8
|
+
* after a sign-in; otherwise it surfaces as a re-auth prompt.
|
|
9
|
+
*/
|
|
10
|
+
export declare const REMINT_THRESHOLD_MS: number;
|
|
11
|
+
export interface MintedApiKey {
|
|
12
|
+
apiKey: string;
|
|
13
|
+
keyId: string;
|
|
14
|
+
apiKeyExpiresAt?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface CreateApiKeyInputShape {
|
|
17
|
+
keyName: string;
|
|
18
|
+
accessLevel: string;
|
|
19
|
+
mfaLevel: string;
|
|
20
|
+
expiresIn: {
|
|
21
|
+
years: number;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export declare function buildCreateApiKeyInput({ years }: {
|
|
25
|
+
years: number;
|
|
26
|
+
}): CreateApiKeyInputShape;
|
|
27
|
+
interface RawApiKeyRecord {
|
|
28
|
+
apiKey?: string | null;
|
|
29
|
+
keyId?: string | null;
|
|
30
|
+
expiresAt?: string | null;
|
|
31
|
+
}
|
|
32
|
+
export declare function parseMintedKey(record: RawApiKeyRecord | null | undefined): MintedApiKey;
|
|
33
|
+
export type ApiKeyErrorKind = 'step-up-required' | 'disabled' | 'limit-reached' | 'not-authenticated' | 'invalid-access-level' | 'unknown';
|
|
34
|
+
export declare function classifyApiKeyError(err: unknown): ApiKeyErrorKind;
|
|
35
|
+
export declare function needsRemint({ apiKeyExpiresAt, now, thresholdMs }: {
|
|
36
|
+
apiKeyExpiresAt: string | undefined;
|
|
37
|
+
now: number;
|
|
38
|
+
thresholdMs?: number;
|
|
39
|
+
}): boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Mint-first, revoke-on-success: revoking before minting would destroy a
|
|
42
|
+
* still-valid key whenever the mint then fails, leaving no credential at all.
|
|
43
|
+
* The one case that needs the old key gone first is the live-key cap
|
|
44
|
+
* (API_KEY_LIMIT_REACHED): there we revoke, then retry the mint once.
|
|
45
|
+
*/
|
|
46
|
+
export declare function remintApiKey({ currentKeyId, revoke, mint }: {
|
|
47
|
+
currentKeyId: string | undefined;
|
|
48
|
+
revoke: (keyId: string) => Promise<void>;
|
|
49
|
+
mint: () => Promise<MintedApiKey>;
|
|
50
|
+
}): Promise<MintedApiKey>;
|
|
51
|
+
export {};
|
package/api-key.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.REMINT_THRESHOLD_MS = exports.API_KEY_YEARS = exports.API_KEY_MFA_LEVEL = exports.API_KEY_ACCESS_LEVEL = exports.API_KEY_NAME = void 0;
|
|
4
|
+
exports.buildCreateApiKeyInput = buildCreateApiKeyInput;
|
|
5
|
+
exports.parseMintedKey = parseMintedKey;
|
|
6
|
+
exports.classifyApiKeyError = classifyApiKeyError;
|
|
7
|
+
exports.needsRemint = needsRemint;
|
|
8
|
+
exports.remintApiKey = remintApiKey;
|
|
9
|
+
exports.API_KEY_NAME = 'agentic-cli';
|
|
10
|
+
exports.API_KEY_ACCESS_LEVEL = 'full_access';
|
|
11
|
+
exports.API_KEY_MFA_LEVEL = 'none';
|
|
12
|
+
exports.API_KEY_YEARS = 5;
|
|
13
|
+
/**
|
|
14
|
+
* Re-mint once the key is within a week of expiry. The mint needs a
|
|
15
|
+
* fresh-password step-up window, so in practice the refresh succeeds only right
|
|
16
|
+
* after a sign-in; otherwise it surfaces as a re-auth prompt.
|
|
17
|
+
*/
|
|
18
|
+
exports.REMINT_THRESHOLD_MS = 1000 * 60 * 60 * 24 * 7;
|
|
19
|
+
function buildCreateApiKeyInput({ years }) {
|
|
20
|
+
return {
|
|
21
|
+
keyName: exports.API_KEY_NAME,
|
|
22
|
+
accessLevel: exports.API_KEY_ACCESS_LEVEL,
|
|
23
|
+
mfaLevel: exports.API_KEY_MFA_LEVEL,
|
|
24
|
+
expiresIn: { years }
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function parseMintedKey(record) {
|
|
28
|
+
if (!record?.apiKey || !record.keyId) {
|
|
29
|
+
throw new Error('createApiKey returned no API key.');
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
apiKey: record.apiKey,
|
|
33
|
+
keyId: record.keyId,
|
|
34
|
+
...(record.expiresAt ? { apiKeyExpiresAt: record.expiresAt } : {})
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
const ERROR_CODE_MAP = {
|
|
38
|
+
STEP_UP_REQUIRED: 'step-up-required',
|
|
39
|
+
API_KEYS_DISABLED: 'disabled',
|
|
40
|
+
API_KEY_LIMIT_REACHED: 'limit-reached',
|
|
41
|
+
NOT_AUTHENTICATED: 'not-authenticated',
|
|
42
|
+
INVALID_ACCESS_LEVEL: 'invalid-access-level'
|
|
43
|
+
};
|
|
44
|
+
function classifyApiKeyError(err) {
|
|
45
|
+
const errors = err?.errors;
|
|
46
|
+
if (Array.isArray(errors)) {
|
|
47
|
+
for (const e of errors) {
|
|
48
|
+
const code = e.extensions?.code;
|
|
49
|
+
if (code && ERROR_CODE_MAP[code])
|
|
50
|
+
return ERROR_CODE_MAP[code];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const message = err instanceof Error ? err.message : typeof err === 'string' ? err : '';
|
|
54
|
+
for (const code of Object.keys(ERROR_CODE_MAP)) {
|
|
55
|
+
if (message.includes(code))
|
|
56
|
+
return ERROR_CODE_MAP[code];
|
|
57
|
+
}
|
|
58
|
+
return 'unknown';
|
|
59
|
+
}
|
|
60
|
+
function needsRemint({ apiKeyExpiresAt, now, thresholdMs = exports.REMINT_THRESHOLD_MS }) {
|
|
61
|
+
if (!apiKeyExpiresAt)
|
|
62
|
+
return false;
|
|
63
|
+
const expires = Date.parse(apiKeyExpiresAt);
|
|
64
|
+
if (Number.isNaN(expires))
|
|
65
|
+
return false;
|
|
66
|
+
return expires - now <= thresholdMs;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Mint-first, revoke-on-success: revoking before minting would destroy a
|
|
70
|
+
* still-valid key whenever the mint then fails, leaving no credential at all.
|
|
71
|
+
* The one case that needs the old key gone first is the live-key cap
|
|
72
|
+
* (API_KEY_LIMIT_REACHED): there we revoke, then retry the mint once.
|
|
73
|
+
*/
|
|
74
|
+
async function remintApiKey({ currentKeyId, revoke, mint }) {
|
|
75
|
+
const revokeOld = async () => {
|
|
76
|
+
if (!currentKeyId)
|
|
77
|
+
return;
|
|
78
|
+
try {
|
|
79
|
+
await revoke(currentKeyId);
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
console.warn(`[agent] failed to revoke superseded API key ${currentKeyId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
try {
|
|
86
|
+
const minted = await mint();
|
|
87
|
+
await revokeOld();
|
|
88
|
+
return minted;
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
if (classifyApiKeyError(err) !== 'limit-reached')
|
|
92
|
+
throw err;
|
|
93
|
+
await revokeOld();
|
|
94
|
+
return mint();
|
|
95
|
+
}
|
|
96
|
+
}
|
package/auth-error.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare function isNetworkError(err: unknown): boolean;
|
|
2
|
+
export declare function describeAuthError(err: unknown, endpoint: string): string;
|
|
3
|
+
export declare const AUTH_TIMEOUT_MS = 10000;
|
|
4
|
+
export declare function withAuthTimeout<T>(promise: Promise<T>, endpoint: string, ms?: number): Promise<T>;
|
package/auth-error.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.AUTH_TIMEOUT_MS = void 0;
|
|
4
|
+
exports.isNetworkError = isNetworkError;
|
|
5
|
+
exports.describeAuthError = describeAuthError;
|
|
6
|
+
exports.withAuthTimeout = withAuthTimeout;
|
|
7
|
+
const NETWORK_ERROR_CODES = new Set([
|
|
8
|
+
'ECONNREFUSED',
|
|
9
|
+
'ECONNRESET',
|
|
10
|
+
'ENOTFOUND',
|
|
11
|
+
'EAI_AGAIN',
|
|
12
|
+
'EHOSTUNREACH',
|
|
13
|
+
'ENETUNREACH',
|
|
14
|
+
'ETIMEDOUT',
|
|
15
|
+
'UND_ERR_CONNECT_TIMEOUT',
|
|
16
|
+
'UND_ERR_SOCKET'
|
|
17
|
+
]);
|
|
18
|
+
function errorCode(err) {
|
|
19
|
+
if (!err || typeof err !== 'object')
|
|
20
|
+
return undefined;
|
|
21
|
+
const { code, cause } = err;
|
|
22
|
+
if (typeof code === 'string')
|
|
23
|
+
return code;
|
|
24
|
+
if (cause && typeof cause === 'object') {
|
|
25
|
+
const causeCode = cause.code;
|
|
26
|
+
if (typeof causeCode === 'string')
|
|
27
|
+
return causeCode;
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
function isNetworkError(err) {
|
|
32
|
+
const code = errorCode(err);
|
|
33
|
+
if (code !== undefined)
|
|
34
|
+
return NETWORK_ERROR_CODES.has(code);
|
|
35
|
+
if (err instanceof AggregateError)
|
|
36
|
+
return true;
|
|
37
|
+
return err instanceof TypeError && /fetch failed/i.test(err.message);
|
|
38
|
+
}
|
|
39
|
+
function extractGraphqlMessages(err) {
|
|
40
|
+
const messages = [];
|
|
41
|
+
const codes = [];
|
|
42
|
+
const visited = new Set();
|
|
43
|
+
const visit = (value) => {
|
|
44
|
+
if (!value || typeof value !== 'object' || visited.has(value))
|
|
45
|
+
return;
|
|
46
|
+
visited.add(value);
|
|
47
|
+
const record = value;
|
|
48
|
+
if (typeof record.message === 'string' && record.message.trim() !== '') {
|
|
49
|
+
const message = record.message.trim().replace(/^GraphQL Error:\s*/u, '');
|
|
50
|
+
if (message.length > 0)
|
|
51
|
+
messages.push(message);
|
|
52
|
+
}
|
|
53
|
+
const extensions = record.extensions && typeof record.extensions === 'object'
|
|
54
|
+
? record.extensions
|
|
55
|
+
: undefined;
|
|
56
|
+
if (typeof extensions?.code === 'string')
|
|
57
|
+
codes.push(extensions.code);
|
|
58
|
+
if (Array.isArray(record.errors))
|
|
59
|
+
record.errors.forEach(visit);
|
|
60
|
+
if (record.cause)
|
|
61
|
+
visit(record.cause);
|
|
62
|
+
if (record.extensions)
|
|
63
|
+
visit(record.extensions);
|
|
64
|
+
if (record.detail)
|
|
65
|
+
visit(record.detail);
|
|
66
|
+
if (record.data)
|
|
67
|
+
visit(record.data);
|
|
68
|
+
if (record.response)
|
|
69
|
+
visit(record.response);
|
|
70
|
+
if (record.result)
|
|
71
|
+
visit(record.result);
|
|
72
|
+
if (record.payload)
|
|
73
|
+
visit(record.payload);
|
|
74
|
+
};
|
|
75
|
+
visit(err);
|
|
76
|
+
if (messages.length === 0) {
|
|
77
|
+
for (const code of codes) {
|
|
78
|
+
if (code === 'UNAUTHENTICATED')
|
|
79
|
+
messages.push('Your session has expired. Sign in again.');
|
|
80
|
+
if (code === 'FORBIDDEN') {
|
|
81
|
+
messages.push('The server rejected this request. Check your account permissions.');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return [...new Set(messages)];
|
|
86
|
+
}
|
|
87
|
+
function describeAuthError(err, endpoint) {
|
|
88
|
+
if (isNetworkError(err)) {
|
|
89
|
+
return `Could not reach the server at ${endpoint}. Check that it is running, or pick a different backend.`;
|
|
90
|
+
}
|
|
91
|
+
const graphqlMessages = extractGraphqlMessages(err);
|
|
92
|
+
if (graphqlMessages.length > 0)
|
|
93
|
+
return graphqlMessages.join('; ');
|
|
94
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
95
|
+
if (message.trim() !== '' && message !== '[object Object]')
|
|
96
|
+
return message;
|
|
97
|
+
const name = err instanceof Error ? err.name : typeof err;
|
|
98
|
+
return `Authentication failed unexpectedly (${name}).`;
|
|
99
|
+
}
|
|
100
|
+
exports.AUTH_TIMEOUT_MS = 10_000;
|
|
101
|
+
function withAuthTimeout(promise, endpoint, ms = exports.AUTH_TIMEOUT_MS) {
|
|
102
|
+
return new Promise((resolve, reject) => {
|
|
103
|
+
const timer = setTimeout(() => {
|
|
104
|
+
reject(new Error(`The server at ${endpoint} did not respond within ${Math.round(ms / 1000)}s. Check the backend settings and try again.`));
|
|
105
|
+
}, ms);
|
|
106
|
+
promise.then(value => {
|
|
107
|
+
clearTimeout(timer);
|
|
108
|
+
resolve(value);
|
|
109
|
+
}, (err) => {
|
|
110
|
+
clearTimeout(timer);
|
|
111
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
}
|
package/auth.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { ConfigStore } from 'appstash';
|
|
2
|
+
import { AccountSession } from './account-store';
|
|
3
|
+
export type ApiKeyRefreshStatus = 'ok' | 'reminted' | 'reauth-required' | 'unavailable' | 'signed-out';
|
|
4
|
+
/**
|
|
5
|
+
* Ensure the stored session carries a usable, not-about-to-expire API key.
|
|
6
|
+
* Never throws: returns a status the caller can surface. A mint/re-mint needs
|
|
7
|
+
* the fresh-password step-up window, so it succeeds right after a sign-in and
|
|
8
|
+
* degrades to 'reauth-required' when attempted cold.
|
|
9
|
+
*/
|
|
10
|
+
export declare function refreshApiKeyIfNeeded({ store, context, authEndpoint }: {
|
|
11
|
+
store: ConfigStore;
|
|
12
|
+
/** Backend context the session is filed under; defaults to the active one. */
|
|
13
|
+
context?: string;
|
|
14
|
+
authEndpoint: string;
|
|
15
|
+
}): Promise<ApiKeyRefreshStatus>;
|
|
16
|
+
export declare function signIn({ store, context, authEndpoint, email, password }: {
|
|
17
|
+
store: ConfigStore;
|
|
18
|
+
context?: string;
|
|
19
|
+
authEndpoint: string;
|
|
20
|
+
email: string;
|
|
21
|
+
password: string;
|
|
22
|
+
}): Promise<AccountSession>;
|
|
23
|
+
export declare function signOut({ store, context, authEndpoint }: {
|
|
24
|
+
store: ConfigStore;
|
|
25
|
+
/** Backend context the session is filed under; defaults to the active one. */
|
|
26
|
+
context?: string;
|
|
27
|
+
authEndpoint: string;
|
|
28
|
+
}): Promise<boolean>;
|
package/auth.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.refreshApiKeyIfNeeded = refreshApiKeyIfNeeded;
|
|
4
|
+
exports.signIn = signIn;
|
|
5
|
+
exports.signOut = signOut;
|
|
6
|
+
const sdk_1 = require("@constructive-io/sdk");
|
|
7
|
+
const account_store_1 = require("./account-store");
|
|
8
|
+
const api_key_1 = require("./api-key");
|
|
9
|
+
const auth_error_1 = require("./auth-error");
|
|
10
|
+
const SELECT = {
|
|
11
|
+
result: {
|
|
12
|
+
select: { userId: true, accessToken: true, accessTokenExpiresAt: true }
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
const CREATE_KEY_SELECT = {
|
|
16
|
+
result: { select: { apiKey: true, keyId: true, expiresAt: true } }
|
|
17
|
+
};
|
|
18
|
+
const REVOKE_KEY_SELECT = { result: true };
|
|
19
|
+
function authedClient(endpoint, bearer) {
|
|
20
|
+
return sdk_1.auth.createClient({
|
|
21
|
+
endpoint,
|
|
22
|
+
headers: { Authorization: `Bearer ${bearer}` }
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
async function mint(client) {
|
|
26
|
+
const data = (await client.mutation
|
|
27
|
+
.createApiKey({ input: (0, api_key_1.buildCreateApiKeyInput)({ years: api_key_1.API_KEY_YEARS }) }, { select: CREATE_KEY_SELECT })
|
|
28
|
+
.unwrap());
|
|
29
|
+
return (0, api_key_1.parseMintedKey)(data?.createApiKey?.result);
|
|
30
|
+
}
|
|
31
|
+
async function revoke(client, keyId) {
|
|
32
|
+
await client.mutation.revokeApiKey({ input: { keyId } }, { select: REVOKE_KEY_SELECT }).unwrap();
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Ensure the stored session carries a usable, not-about-to-expire API key.
|
|
36
|
+
* Never throws: returns a status the caller can surface. A mint/re-mint needs
|
|
37
|
+
* the fresh-password step-up window, so it succeeds right after a sign-in and
|
|
38
|
+
* degrades to 'reauth-required' when attempted cold.
|
|
39
|
+
*/
|
|
40
|
+
async function refreshApiKeyIfNeeded({ store, context, authEndpoint }) {
|
|
41
|
+
const session = (0, account_store_1.loadSession)(store, context);
|
|
42
|
+
if (!session)
|
|
43
|
+
return 'signed-out';
|
|
44
|
+
const hasKey = !!session.apiKey;
|
|
45
|
+
const due = (0, api_key_1.needsRemint)({
|
|
46
|
+
apiKeyExpiresAt: session.apiKeyExpiresAt,
|
|
47
|
+
now: Date.now()
|
|
48
|
+
});
|
|
49
|
+
if (hasKey && !due)
|
|
50
|
+
return 'ok';
|
|
51
|
+
const client = authedClient(authEndpoint, session.accessToken);
|
|
52
|
+
try {
|
|
53
|
+
const minted = await (0, api_key_1.remintApiKey)({
|
|
54
|
+
currentKeyId: session.keyId,
|
|
55
|
+
revoke: (keyId) => revoke(client, keyId),
|
|
56
|
+
mint: () => mint(client)
|
|
57
|
+
});
|
|
58
|
+
(0, account_store_1.saveSession)(store, {
|
|
59
|
+
...session,
|
|
60
|
+
apiKey: minted.apiKey,
|
|
61
|
+
keyId: minted.keyId,
|
|
62
|
+
apiKeyExpiresAt: minted.apiKeyExpiresAt
|
|
63
|
+
}, context);
|
|
64
|
+
return 'reminted';
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
const kind = (0, api_key_1.classifyApiKeyError)(err);
|
|
68
|
+
if (kind === 'step-up-required' || kind === 'not-authenticated')
|
|
69
|
+
return 'reauth-required';
|
|
70
|
+
console.warn(`[agent] API key refresh failed (${kind}): ${(0, auth_error_1.describeAuthError)(err, authEndpoint)}`);
|
|
71
|
+
return 'unavailable';
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
async function signIn({ store, context, authEndpoint, email, password }) {
|
|
75
|
+
const trimmedEmail = email.trim();
|
|
76
|
+
if (!trimmedEmail || !password)
|
|
77
|
+
throw new Error('Email and password are required.');
|
|
78
|
+
const client = sdk_1.auth.createClient({ endpoint: authEndpoint });
|
|
79
|
+
const request = client.mutation
|
|
80
|
+
.signIn({ input: { email: trimmedEmail, password } }, { select: SELECT })
|
|
81
|
+
.unwrap();
|
|
82
|
+
let data;
|
|
83
|
+
try {
|
|
84
|
+
data = (await (0, auth_error_1.withAuthTimeout)(request, authEndpoint));
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
throw new Error((0, auth_error_1.describeAuthError)(err, authEndpoint));
|
|
88
|
+
}
|
|
89
|
+
const record = data?.signIn?.result;
|
|
90
|
+
if (!record?.accessToken || !record.userId) {
|
|
91
|
+
throw new Error('Authentication returned no access token (MFA may be required).');
|
|
92
|
+
}
|
|
93
|
+
(0, account_store_1.saveSession)(store, {
|
|
94
|
+
userId: record.userId,
|
|
95
|
+
email: trimmedEmail,
|
|
96
|
+
accessToken: record.accessToken,
|
|
97
|
+
...(record.accessTokenExpiresAt ? { accessTokenExpiresAt: record.accessTokenExpiresAt } : {}),
|
|
98
|
+
signedInAt: Date.now()
|
|
99
|
+
}, context);
|
|
100
|
+
// Mint the long-lived API key inside the fresh step-up window. Best-effort: a
|
|
101
|
+
// mint failure leaves a valid signed-in session that lacks a key until re-auth.
|
|
102
|
+
await refreshApiKeyIfNeeded({ store, context, authEndpoint });
|
|
103
|
+
return (0, account_store_1.loadSession)(store, context);
|
|
104
|
+
}
|
|
105
|
+
async function signOut({ store, context, authEndpoint }) {
|
|
106
|
+
const session = (0, account_store_1.loadSession)(store, context);
|
|
107
|
+
if (!session)
|
|
108
|
+
return false;
|
|
109
|
+
if (session.keyId) {
|
|
110
|
+
try {
|
|
111
|
+
await revoke(authedClient(authEndpoint, session.accessToken), session.keyId);
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
console.warn(`[agent] API key revoke on sign-out failed: ${(0, auth_error_1.describeAuthError)(err, authEndpoint)}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
(0, account_store_1.clearSession)(store, context);
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { ConfigStore } from 'appstash';
|
|
2
|
+
export interface BackendConfig {
|
|
3
|
+
apiEndpoint: string;
|
|
4
|
+
authEndpoint: string;
|
|
5
|
+
modulesEndpoint: string;
|
|
6
|
+
}
|
|
7
|
+
export declare const BACKEND_PRESETS: Record<string, BackendConfig>;
|
|
8
|
+
/** Context name used for endpoints that match no preset. */
|
|
9
|
+
export declare const CUSTOM_CONTEXT = "custom";
|
|
10
|
+
/**
|
|
11
|
+
* A chosen backend is a named context in the shared store: the preset name when
|
|
12
|
+
* the endpoints match one, `custom` otherwise. Credentials hang off the context,
|
|
13
|
+
* so signing in against localnet and devnet keeps two independent sessions.
|
|
14
|
+
*/
|
|
15
|
+
export declare function contextNameFor(config: BackendConfig): string;
|
|
16
|
+
/** Endpoints of the active context, or null when no backend has been chosen. */
|
|
17
|
+
export declare function loadBackendConfig(store: ConfigStore): BackendConfig | null;
|
|
18
|
+
/** Persist the endpoints as a context and make it active. */
|
|
19
|
+
export declare function saveBackendConfig(store: ConfigStore, config: BackendConfig): string;
|
package/backend-store.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CUSTOM_CONTEXT = exports.BACKEND_PRESETS = void 0;
|
|
4
|
+
exports.contextNameFor = contextNameFor;
|
|
5
|
+
exports.loadBackendConfig = loadBackendConfig;
|
|
6
|
+
exports.saveBackendConfig = saveBackendConfig;
|
|
7
|
+
exports.BACKEND_PRESETS = {
|
|
8
|
+
localnet: {
|
|
9
|
+
apiEndpoint: 'http://api.localhost:3000/graphql',
|
|
10
|
+
authEndpoint: 'http://auth.localhost:3000/graphql',
|
|
11
|
+
modulesEndpoint: 'http://modules.localhost:3000/graphql'
|
|
12
|
+
},
|
|
13
|
+
devnet: {
|
|
14
|
+
apiEndpoint: 'https://api.launchql.dev/graphql',
|
|
15
|
+
authEndpoint: 'https://auth.launchql.dev/graphql',
|
|
16
|
+
modulesEndpoint: 'https://modules.launchql.dev/graphql'
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
/** Context name used for endpoints that match no preset. */
|
|
20
|
+
exports.CUSTOM_CONTEXT = 'custom';
|
|
21
|
+
/**
|
|
22
|
+
* A chosen backend is a named context in the shared store: the preset name when
|
|
23
|
+
* the endpoints match one, `custom` otherwise. Credentials hang off the context,
|
|
24
|
+
* so signing in against localnet and devnet keeps two independent sessions.
|
|
25
|
+
*/
|
|
26
|
+
function contextNameFor(config) {
|
|
27
|
+
for (const [name, preset] of Object.entries(exports.BACKEND_PRESETS)) {
|
|
28
|
+
if (preset.apiEndpoint === config.apiEndpoint &&
|
|
29
|
+
preset.authEndpoint === config.authEndpoint &&
|
|
30
|
+
preset.modulesEndpoint === config.modulesEndpoint) {
|
|
31
|
+
return name;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return exports.CUSTOM_CONTEXT;
|
|
35
|
+
}
|
|
36
|
+
function toBackendConfig(targets) {
|
|
37
|
+
const apiEndpoint = targets?.api?.endpoint;
|
|
38
|
+
const authEndpoint = targets?.auth?.endpoint;
|
|
39
|
+
const modulesEndpoint = targets?.modules?.endpoint;
|
|
40
|
+
if (!apiEndpoint || !authEndpoint || !modulesEndpoint)
|
|
41
|
+
return null;
|
|
42
|
+
return { apiEndpoint, authEndpoint, modulesEndpoint };
|
|
43
|
+
}
|
|
44
|
+
/** Endpoints of the active context, or null when no backend has been chosen. */
|
|
45
|
+
function loadBackendConfig(store) {
|
|
46
|
+
const ctx = store.getCurrentContext();
|
|
47
|
+
if (!ctx)
|
|
48
|
+
return null;
|
|
49
|
+
return toBackendConfig(ctx.targets);
|
|
50
|
+
}
|
|
51
|
+
/** Persist the endpoints as a context and make it active. */
|
|
52
|
+
function saveBackendConfig(store, config) {
|
|
53
|
+
const name = contextNameFor(config);
|
|
54
|
+
store.createContext(name, {
|
|
55
|
+
endpoint: config.apiEndpoint,
|
|
56
|
+
targets: {
|
|
57
|
+
api: { endpoint: config.apiEndpoint },
|
|
58
|
+
auth: { endpoint: config.authEndpoint },
|
|
59
|
+
modules: { endpoint: config.modulesEndpoint }
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
store.setCurrentContext(name);
|
|
63
|
+
return name;
|
|
64
|
+
}
|
package/commands.d.ts
CHANGED
|
@@ -2,4 +2,7 @@ import { AgentCliConfig } from './config';
|
|
|
2
2
|
export declare function skillsList(config: AgentCliConfig): Promise<void>;
|
|
3
3
|
export declare function skillsUpdate(config: AgentCliConfig): Promise<void>;
|
|
4
4
|
export declare function init(config: AgentCliConfig, argv: Record<string, unknown>): Promise<void>;
|
|
5
|
+
export declare function login(config: AgentCliConfig, argv: Record<string, unknown>): Promise<void>;
|
|
6
|
+
export declare function logout(config: AgentCliConfig): Promise<void>;
|
|
7
|
+
export declare function whoami(config: AgentCliConfig): void;
|
|
5
8
|
export declare function usage(): void;
|