From 46314faf2641b408a894d0f9f584c335e593bf03 Mon Sep 17 00:00:00 2001 From: luca Date: Mon, 3 Aug 2026 22:25:56 +0800 Subject: [PATCH 01/19] feat(cli): add account session store and auth core --- agentic/cli/__tests__/api-key.test.ts | 135 ++++++++++++++++ agentic/cli/__tests__/auth.test.ts | 224 ++++++++++++++++++++++++++ agentic/cli/__tests__/stores.test.ts | 96 +++++++++++ agentic/cli/package.json | 1 + agentic/cli/src/account-store.ts | 96 +++++++++++ agentic/cli/src/api-key.ts | 133 +++++++++++++++ agentic/cli/src/auth-error.ts | 107 ++++++++++++ agentic/cli/src/auth.ts | 153 ++++++++++++++++++ agentic/cli/src/backend-store.ts | 49 ++++++ agentic/cli/src/config.ts | 10 ++ pnpm-lock.yaml | 82 +++------- 11 files changed, 1028 insertions(+), 58 deletions(-) create mode 100644 agentic/cli/__tests__/api-key.test.ts create mode 100644 agentic/cli/__tests__/auth.test.ts create mode 100644 agentic/cli/__tests__/stores.test.ts create mode 100644 agentic/cli/src/account-store.ts create mode 100644 agentic/cli/src/api-key.ts create mode 100644 agentic/cli/src/auth-error.ts create mode 100644 agentic/cli/src/auth.ts create mode 100644 agentic/cli/src/backend-store.ts diff --git a/agentic/cli/__tests__/api-key.test.ts b/agentic/cli/__tests__/api-key.test.ts new file mode 100644 index 000000000..ced622b50 --- /dev/null +++ b/agentic/cli/__tests__/api-key.test.ts @@ -0,0 +1,135 @@ +import { + API_KEY_NAME, + buildCreateApiKeyInput, + classifyApiKeyError, + MintedApiKey, + needsRemint, + parseMintedKey, + remintApiKey, + REMINT_THRESHOLD_MS +} from '../src/api-key'; + +describe('buildCreateApiKeyInput', () => { + it('names the key agentic-cli with full access and no MFA', () => { + expect(buildCreateApiKeyInput({ years: 5 })).toEqual({ + keyName: API_KEY_NAME, + accessLevel: 'full_access', + mfaLevel: 'none', + expiresIn: { years: 5 } + }); + expect(API_KEY_NAME).toBe('agentic-cli'); + }); +}); + +describe('parseMintedKey', () => { + it('returns the minted key and maps expiresAt', () => { + expect(parseMintedKey({ apiKey: 'k', keyId: 'id', expiresAt: '2031-01-01' })).toEqual({ + apiKey: 'k', + keyId: 'id', + apiKeyExpiresAt: '2031-01-01' + }); + expect(parseMintedKey({ apiKey: 'k', keyId: 'id' })).toEqual({ apiKey: 'k', keyId: 'id' }); + }); + + it('throws when the backend returns no secret', () => { + expect(() => parseMintedKey(undefined)).toThrow('createApiKey returned no API key.'); + expect(() => parseMintedKey({ keyId: 'id' })).toThrow('createApiKey returned no API key.'); + }); +}); + +describe('classifyApiKeyError', () => { + it('maps extension codes', () => { + const err = { errors: [{ extensions: { code: 'STEP_UP_REQUIRED' } }] }; + expect(classifyApiKeyError(err)).toBe('step-up-required'); + }); + + it('falls back to message text and then unknown', () => { + expect(classifyApiKeyError(new Error('boom: API_KEY_LIMIT_REACHED'))).toBe('limit-reached'); + expect(classifyApiKeyError(new Error('NOT_AUTHENTICATED'))).toBe('not-authenticated'); + expect(classifyApiKeyError(new Error('nope'))).toBe('unknown'); + expect(classifyApiKeyError(undefined)).toBe('unknown'); + }); +}); + +describe('needsRemint', () => { + const now = Date.parse('2026-08-03T00:00:00Z'); + + it('is false without an expiry or with an unparsable one', () => { + expect(needsRemint({ apiKeyExpiresAt: undefined, now })).toBe(false); + expect(needsRemint({ apiKeyExpiresAt: 'garbage', now })).toBe(false); + }); + + it('is true at or inside the 7-day threshold, false outside', () => { + const at = (deltaMs: number) => new Date(now + deltaMs).toISOString(); + expect(needsRemint({ apiKeyExpiresAt: at(REMINT_THRESHOLD_MS), now })).toBe(true); + expect(needsRemint({ apiKeyExpiresAt: at(REMINT_THRESHOLD_MS + 1000), now })).toBe(false); + expect(needsRemint({ apiKeyExpiresAt: at(-1000), now })).toBe(true); + }); +}); + +describe('remintApiKey', () => { + const minted: MintedApiKey = { apiKey: 'new', keyId: 'new-id' }; + + it('mints first, then revokes the old key', async () => { + const calls: string[] = []; + const result = await remintApiKey({ + currentKeyId: 'old-id', + revoke: async keyId => { + calls.push(`revoke:${keyId}`); + }, + mint: async () => { + calls.push('mint'); + return minted; + } + }); + expect(result).toEqual(minted); + expect(calls).toEqual(['mint', 'revoke:old-id']); + }); + + it('on limit-reached, revokes first and retries the mint once', async () => { + const calls: string[] = []; + let attempts = 0; + const result = await remintApiKey({ + currentKeyId: 'old-id', + revoke: async keyId => { + calls.push(`revoke:${keyId}`); + }, + mint: async () => { + calls.push('mint'); + attempts += 1; + if (attempts === 1) throw { errors: [{ extensions: { code: 'API_KEY_LIMIT_REACHED' } }] }; + return minted; + } + }); + expect(result).toEqual(minted); + expect(calls).toEqual(['mint', 'revoke:old-id', 'mint']); + }); + + it('rethrows non-limit errors without revoking', async () => { + const revoke = jest.fn(); + await expect( + remintApiKey({ + currentKeyId: 'old-id', + revoke, + mint: async () => { + throw { errors: [{ extensions: { code: 'STEP_UP_REQUIRED' } }] }; + } + }) + ).rejects.toBeDefined(); + expect(revoke).not.toHaveBeenCalled(); + }); + + it('tolerates a failing revoke', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + const result = await remintApiKey({ + currentKeyId: 'old-id', + revoke: async () => { + throw new Error('offline'); + }, + mint: async () => minted + }); + expect(result).toEqual(minted); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); +}); diff --git a/agentic/cli/__tests__/auth.test.ts b/agentic/cli/__tests__/auth.test.ts new file mode 100644 index 000000000..aa123ca34 --- /dev/null +++ b/agentic/cli/__tests__/auth.test.ts @@ -0,0 +1,224 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const createClient = jest.fn(); + +jest.mock('@constructive-io/sdk', () => ({ + auth: { createClient: (...args: any[]) => createClient(...args) } +})); + +import { loadSession, saveSession } from '../src/account-store'; +import { refreshApiKeyIfNeeded, signIn, signOut } from '../src/auth'; +import { loadConfig } from '../src/config'; + +const AUTH_ENDPOINT = 'http://auth.localhost:3000/graphql'; + +const unwrappable = (value: unknown) => ({ unwrap: () => Promise.resolve(value) }); +const failing = (err: unknown) => ({ unwrap: () => Promise.reject(err) }); + +let home: string; +let accountFile: string; + +beforeEach(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-cli-auth-')); + accountFile = loadConfig(home).accountFile; + createClient.mockReset(); +}); + +afterEach(() => { + fs.rmSync(home, { recursive: true, force: true }); +}); + +function mockClient(mutation: Record) { + createClient.mockImplementation(() => ({ mutation })); + return mutation; +} + +const signInResult = { + signIn: { + result: { + userId: 'user-1', + accessToken: 'access-token', + accessTokenExpiresAt: '2026-08-04T00:00:00Z' + } + } +}; + +const mintedResult = { + createApiKey: { + result: { apiKey: 'cnc_live_sk_new', keyId: 'key-new', expiresAt: '2031-08-03T00:00:00Z' } + } +}; + +describe('signIn', () => { + it('persists the session and mints an API key in the step-up window', async () => { + const mutation = mockClient({ + signIn: jest.fn(() => unwrappable(signInResult)), + createApiKey: jest.fn(() => unwrappable(mintedResult)), + revokeApiKey: jest.fn() + }); + + const session = await signIn({ + accountFile, + authEndpoint: AUTH_ENDPOINT, + email: ' dev@example.com ', + password: 'pw' + }); + + expect(mutation.signIn).toHaveBeenCalledWith( + { input: { email: 'dev@example.com', password: 'pw' } }, + expect.anything() + ); + expect(session.userId).toBe('user-1'); + expect(session.apiKey).toBe('cnc_live_sk_new'); + expect(session.keyId).toBe('key-new'); + expect(loadSession(accountFile)).toEqual(session); + expect(createClient).toHaveBeenCalledWith({ endpoint: AUTH_ENDPOINT }); + expect(createClient).toHaveBeenCalledWith({ + endpoint: AUTH_ENDPOINT, + headers: { Authorization: 'Bearer access-token' } + }); + }); + + it('keeps the session when the mint fails', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + mockClient({ + signIn: jest.fn(() => unwrappable(signInResult)), + createApiKey: jest.fn(() => failing(new Error('API_KEYS_DISABLED'))), + revokeApiKey: jest.fn() + }); + + const session = await signIn({ accountFile, authEndpoint: AUTH_ENDPOINT, email: 'dev@example.com', password: 'pw' }); + expect(session.apiKey).toBeUndefined(); + expect(loadSession(accountFile)?.accessToken).toBe('access-token'); + warn.mockRestore(); + }); + + it('rejects when no access token comes back (MFA)', async () => { + mockClient({ signIn: jest.fn(() => unwrappable({ signIn: { result: {} } })) }); + await expect( + signIn({ accountFile, authEndpoint: AUTH_ENDPOINT, email: 'dev@example.com', password: 'pw' }) + ).rejects.toThrow('Authentication returned no access token (MFA may be required).'); + expect(loadSession(accountFile)).toBeNull(); + }); + + it('rejects empty credentials without a network call', async () => { + await expect( + signIn({ accountFile, authEndpoint: AUTH_ENDPOINT, email: ' ', password: 'pw' }) + ).rejects.toThrow('Email and password are required.'); + expect(createClient).not.toHaveBeenCalled(); + }); + + it('maps sign-in errors through describeAuthError', async () => { + const gqlError = Object.assign(new Error('request failed'), { + errors: [{ message: 'GraphQL Error: Invalid credentials' }] + }); + mockClient({ signIn: jest.fn(() => failing(gqlError)) }); + await expect( + signIn({ accountFile, authEndpoint: AUTH_ENDPOINT, email: 'dev@example.com', password: 'nope' }) + ).rejects.toThrow('Invalid credentials'); + }); +}); + +describe('refreshApiKeyIfNeeded', () => { + const baseSession = { + userId: 'user-1', + email: 'dev@example.com', + accessToken: 'access-token', + signedInAt: 1754000000000 + }; + + it('returns signed-out without a session', async () => { + await expect(refreshApiKeyIfNeeded({ accountFile, authEndpoint: AUTH_ENDPOINT })).resolves.toBe('signed-out'); + }); + + it('returns ok for a fresh key without a network call', async () => { + saveSession(accountFile, { + ...baseSession, + apiKey: 'k', + keyId: 'id', + apiKeyExpiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365).toISOString() + }); + await expect(refreshApiKeyIfNeeded({ accountFile, authEndpoint: AUTH_ENDPOINT })).resolves.toBe('ok'); + expect(createClient).not.toHaveBeenCalled(); + }); + + it('re-mints an expiring key and persists it', async () => { + saveSession(accountFile, { + ...baseSession, + apiKey: 'old', + keyId: 'old-id', + apiKeyExpiresAt: new Date(Date.now() + 1000).toISOString() + }); + const mutation = mockClient({ + createApiKey: jest.fn(() => unwrappable(mintedResult)), + revokeApiKey: jest.fn(() => unwrappable({ revokeApiKey: { result: true } })) + }); + + await expect(refreshApiKeyIfNeeded({ accountFile, authEndpoint: AUTH_ENDPOINT })).resolves.toBe('reminted'); + expect(mutation.revokeApiKey).toHaveBeenCalledWith({ input: { keyId: 'old-id' } }, expect.anything()); + expect(loadSession(accountFile)?.apiKey).toBe('cnc_live_sk_new'); + }); + + it('returns reauth-required on a step-up error', async () => { + saveSession(accountFile, baseSession); + mockClient({ + createApiKey: jest.fn(() => failing({ errors: [{ extensions: { code: 'STEP_UP_REQUIRED' } }] })), + revokeApiKey: jest.fn() + }); + await expect(refreshApiKeyIfNeeded({ accountFile, authEndpoint: AUTH_ENDPOINT })).resolves.toBe('reauth-required'); + }); + + it('returns unavailable on other errors', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + saveSession(accountFile, baseSession); + mockClient({ + createApiKey: jest.fn(() => failing(new Error('boom'))), + revokeApiKey: jest.fn() + }); + await expect(refreshApiKeyIfNeeded({ accountFile, authEndpoint: AUTH_ENDPOINT })).resolves.toBe('unavailable'); + warn.mockRestore(); + }); +}); + +describe('signOut', () => { + it('revokes the key and clears the session', async () => { + saveSession(accountFile, { + userId: 'user-1', + email: 'dev@example.com', + accessToken: 'access-token', + apiKey: 'k', + keyId: 'key-1', + signedInAt: 1754000000000 + }); + const mutation = mockClient({ + revokeApiKey: jest.fn(() => unwrappable({ revokeApiKey: { result: true } })) + }); + + await expect(signOut({ accountFile, authEndpoint: AUTH_ENDPOINT })).resolves.toBe(true); + expect(mutation.revokeApiKey).toHaveBeenCalledWith({ input: { keyId: 'key-1' } }, expect.anything()); + expect(loadSession(accountFile)).toBeNull(); + }); + + it('clears the session even when the revoke fails', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + saveSession(accountFile, { + userId: 'user-1', + email: 'dev@example.com', + accessToken: 'access-token', + keyId: 'key-1', + signedInAt: 1754000000000 + }); + mockClient({ revokeApiKey: jest.fn(() => failing(new Error('offline'))) }); + + await expect(signOut({ accountFile, authEndpoint: AUTH_ENDPOINT })).resolves.toBe(true); + expect(loadSession(accountFile)).toBeNull(); + warn.mockRestore(); + }); + + it('is a no-op when signed out', async () => { + await expect(signOut({ accountFile, authEndpoint: AUTH_ENDPOINT })).resolves.toBe(false); + expect(createClient).not.toHaveBeenCalled(); + }); +}); diff --git a/agentic/cli/__tests__/stores.test.ts b/agentic/cli/__tests__/stores.test.ts new file mode 100644 index 000000000..ff559b134 --- /dev/null +++ b/agentic/cli/__tests__/stores.test.ts @@ -0,0 +1,96 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { AccountSession, clearSession, loadSession, saveSession } from '../src/account-store'; +import { BACKEND_PRESETS, loadBackendConfig, saveBackendConfig } from '../src/backend-store'; +import { loadConfig } from '../src/config'; + +let home: string; + +beforeEach(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-cli-store-')); +}); + +afterEach(() => { + fs.rmSync(home, { recursive: true, force: true }); +}); + +const session: AccountSession = { + userId: 'user-1', + email: 'dev@example.com', + accessToken: 'access-token', + accessTokenExpiresAt: '2026-08-04T00:00:00Z', + apiKey: 'cnc_live_sk_abc', + keyId: 'key-1', + apiKeyExpiresAt: '2031-08-03T00:00:00Z', + signedInAt: 1754000000000 +}; + +describe('config', () => { + it('exposes account and backend file paths under /agent', () => { + const config = loadConfig(home); + expect(config.accountFile).toBe(path.join(config.dirs.stash.config, 'agent', 'account.json')); + expect(config.backendFile).toBe(path.join(config.dirs.stash.config, 'agent', 'backend-config.json')); + expect(fs.existsSync(path.dirname(config.accountFile))).toBe(true); + }); +}); + +describe('account-store', () => { + it('round-trips a session and keeps the airpage StoredSession shape on disk', () => { + const file = loadConfig(home).accountFile; + saveSession(file, session); + expect(loadSession(file)).toEqual(session); + const stored = JSON.parse(fs.readFileSync(file, 'utf8')); + expect(stored.token).toBe('access-token'); + expect(stored.encrypted).toBe(false); + expect(stored.accessToken).toBeUndefined(); + }); + + it('writes the session file with mode 0600', () => { + const file = loadConfig(home).accountFile; + saveSession(file, session); + expect(fs.statSync(file).mode & 0o777).toBe(0o600); + }); + + it('returns null when no session file exists', () => { + expect(loadSession(loadConfig(home).accountFile)).toBeNull(); + }); + + it('moves a corrupt session file aside and returns null', () => { + const file = loadConfig(home).accountFile; + fs.writeFileSync(file, 'not json'); + expect(loadSession(file)).toBeNull(); + expect(fs.existsSync(`${file}.bak`)).toBe(true); + expect(fs.existsSync(file)).toBe(false); + }); + + it('clearSession removes the file and tolerates a missing one', () => { + const file = loadConfig(home).accountFile; + saveSession(file, session); + clearSession(file); + expect(fs.existsSync(file)).toBe(false); + expect(() => clearSession(file)).not.toThrow(); + }); +}); + +describe('backend-store', () => { + it('ships localnet and devnet presets with all three endpoints', () => { + for (const preset of Object.values(BACKEND_PRESETS)) { + expect(preset.apiEndpoint).toMatch(/\/graphql$/); + expect(preset.authEndpoint).toMatch(/\/graphql$/); + expect(preset.modulesEndpoint).toMatch(/\/graphql$/); + } + expect(BACKEND_PRESETS.localnet.authEndpoint).toBe('http://auth.localhost:3000/graphql'); + expect(BACKEND_PRESETS.devnet.authEndpoint).toBe('https://auth.launchql.dev/graphql'); + }); + + it('round-trips a backend config and returns null for missing or invalid files', () => { + const file = loadConfig(home).backendFile; + expect(loadBackendConfig(file)).toBeNull(); + saveBackendConfig(file, BACKEND_PRESETS.devnet); + expect(loadBackendConfig(file)).toEqual(BACKEND_PRESETS.devnet); + fs.writeFileSync(file, '{"apiEndpoint":"x"}'); + expect(loadBackendConfig(file)).toBeNull(); + }); +}); diff --git a/agentic/cli/package.json b/agentic/cli/package.json index 961049728..b332ab063 100644 --- a/agentic/cli/package.json +++ b/agentic/cli/package.json @@ -34,6 +34,7 @@ "dependencies": { "@agentic-kit/harness": "workspace:*", "@agentic-kit/pi": "workspace:*", + "@constructive-io/sdk": "workspace:*", "@earendil-works/pi-coding-agent": "0.79.6", "inquirerer": "^4.9.1" }, diff --git a/agentic/cli/src/account-store.ts b/agentic/cli/src/account-store.ts new file mode 100644 index 000000000..7e4328daf --- /dev/null +++ b/agentic/cli/src/account-store.ts @@ -0,0 +1,96 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +/** On-disk shape, kept identical to airpage's StoredSession for parity. */ +export interface StoredSession { + userId: string; + email: string; + token: string; + /** Always false in the CLI: the token is stored plaintext, protected by file mode 0600. */ + encrypted: boolean; + accessTokenExpiresAt?: string; + apiKey?: string; + keyId?: string; + apiKeyExpiresAt?: string; + signedInAt: number; +} + +export interface AccountSession { + userId: string; + email: string; + accessToken: string; + accessTokenExpiresAt?: string; + apiKey?: string; + keyId?: string; + apiKeyExpiresAt?: string; + signedInAt: number; +} + +function toSession(stored: StoredSession): AccountSession { + return { + userId: stored.userId, + email: stored.email, + accessToken: stored.token, + accessTokenExpiresAt: stored.accessTokenExpiresAt, + apiKey: stored.apiKey, + keyId: stored.keyId, + apiKeyExpiresAt: stored.apiKeyExpiresAt, + signedInAt: stored.signedInAt + }; +} + +function toStored(session: AccountSession): StoredSession { + return { + userId: session.userId, + email: session.email, + token: session.accessToken, + encrypted: false, + accessTokenExpiresAt: session.accessTokenExpiresAt, + apiKey: session.apiKey, + keyId: session.keyId, + apiKeyExpiresAt: session.apiKeyExpiresAt, + signedInAt: session.signedInAt + }; +} + +export function loadSession(file: string): AccountSession | null { + let raw: string; + try { + raw = fs.readFileSync(file, 'utf8'); + } catch (err: any) { + if (err?.code === 'ENOENT') return null; + throw err; + } + let stored: StoredSession; + try { + stored = JSON.parse(raw) as StoredSession; + } catch { + try { + fs.renameSync(file, `${file}.bak`); + } catch { + /* best effort */ + } + return null; + } + if (!stored || typeof stored.token !== 'string' || typeof stored.userId !== 'string') { + return null; + } + return toSession(stored); +} + +export function saveSession(file: string, session: AccountSession): void { + const dir = path.dirname(file); + fs.mkdirSync(dir, { recursive: true }); + const tmp = path.join(dir, `.${path.basename(file)}.tmp`); + fs.writeFileSync(tmp, JSON.stringify(toStored(session), null, 2) + '\n', { mode: 0o600 }); + fs.renameSync(tmp, file); + fs.chmodSync(file, 0o600); +} + +export function clearSession(file: string): void { + try { + fs.unlinkSync(file); + } catch (err: any) { + if (err?.code !== 'ENOENT') throw err; + } +} diff --git a/agentic/cli/src/api-key.ts b/agentic/cli/src/api-key.ts new file mode 100644 index 000000000..26abab082 --- /dev/null +++ b/agentic/cli/src/api-key.ts @@ -0,0 +1,133 @@ +export const API_KEY_NAME = 'agentic-cli'; +export const API_KEY_ACCESS_LEVEL = 'full_access'; +export const API_KEY_MFA_LEVEL = 'none'; +export const API_KEY_YEARS = 5; + +/** + * Re-mint once the key is within a week of expiry. The mint needs a + * fresh-password step-up window, so in practice the refresh succeeds only right + * after a sign-in; otherwise it surfaces as a re-auth prompt. + */ +export const REMINT_THRESHOLD_MS = 1000 * 60 * 60 * 24 * 7; + +export interface MintedApiKey { + apiKey: string; + keyId: string; + apiKeyExpiresAt?: string; +} + +export interface CreateApiKeyInputShape { + keyName: string; + accessLevel: string; + mfaLevel: string; + expiresIn: { years: number }; +} + +export function buildCreateApiKeyInput({ years }: { years: number }): CreateApiKeyInputShape { + return { + keyName: API_KEY_NAME, + accessLevel: API_KEY_ACCESS_LEVEL, + mfaLevel: API_KEY_MFA_LEVEL, + expiresIn: { years } + }; +} + +interface RawApiKeyRecord { + apiKey?: string | null; + keyId?: string | null; + expiresAt?: string | null; +} + +export function parseMintedKey(record: RawApiKeyRecord | null | undefined): MintedApiKey { + if (!record?.apiKey || !record.keyId) { + throw new Error('createApiKey returned no API key.'); + } + return { + apiKey: record.apiKey, + keyId: record.keyId, + ...(record.expiresAt ? { apiKeyExpiresAt: record.expiresAt } : {}) + }; +} + +export type ApiKeyErrorKind = + | 'step-up-required' + | 'disabled' + | 'limit-reached' + | 'not-authenticated' + | 'invalid-access-level' + | 'unknown'; + +const ERROR_CODE_MAP: Record = { + STEP_UP_REQUIRED: 'step-up-required', + API_KEYS_DISABLED: 'disabled', + API_KEY_LIMIT_REACHED: 'limit-reached', + NOT_AUTHENTICATED: 'not-authenticated', + INVALID_ACCESS_LEVEL: 'invalid-access-level' +}; + +export function classifyApiKeyError(err: unknown): ApiKeyErrorKind { + const errors = (err as { errors?: Array<{ message?: string; extensions?: { code?: string } }> })?.errors; + if (Array.isArray(errors)) { + for (const e of errors) { + const code = e.extensions?.code; + if (code && ERROR_CODE_MAP[code]) return ERROR_CODE_MAP[code]; + } + } + const message = err instanceof Error ? err.message : typeof err === 'string' ? err : ''; + for (const code of Object.keys(ERROR_CODE_MAP)) { + if (message.includes(code)) return ERROR_CODE_MAP[code]; + } + return 'unknown'; +} + +export function needsRemint({ + apiKeyExpiresAt, + now, + thresholdMs = REMINT_THRESHOLD_MS +}: { + apiKeyExpiresAt: string | undefined; + now: number; + thresholdMs?: number; +}): boolean { + if (!apiKeyExpiresAt) return false; + const expires = Date.parse(apiKeyExpiresAt); + if (Number.isNaN(expires)) return false; + return expires - now <= thresholdMs; +} + +/** + * Mint-first, revoke-on-success: revoking before minting would destroy a + * still-valid key whenever the mint then fails, leaving no credential at all. + * The one case that needs the old key gone first is the live-key cap + * (API_KEY_LIMIT_REACHED): there we revoke, then retry the mint once. + */ +export async function remintApiKey({ + currentKeyId, + revoke, + mint +}: { + currentKeyId: string | undefined; + revoke: (keyId: string) => Promise; + mint: () => Promise; +}): Promise { + const revokeOld = async () => { + if (!currentKeyId) return; + try { + await revoke(currentKeyId); + } catch (err) { + console.warn( + `[agent] failed to revoke superseded API key ${currentKeyId}: ${err instanceof Error ? err.message : String(err)}` + ); + } + }; + + try { + const minted = await mint(); + await revokeOld(); + return minted; + } catch (err) { + if (classifyApiKeyError(err) !== 'limit-reached') throw err; + await revokeOld(); + return mint(); + } +} diff --git a/agentic/cli/src/auth-error.ts b/agentic/cli/src/auth-error.ts new file mode 100644 index 000000000..7f5114fa2 --- /dev/null +++ b/agentic/cli/src/auth-error.ts @@ -0,0 +1,107 @@ +const NETWORK_ERROR_CODES = new Set([ + 'ECONNREFUSED', + 'ECONNRESET', + 'ENOTFOUND', + 'EAI_AGAIN', + 'EHOSTUNREACH', + 'ENETUNREACH', + 'ETIMEDOUT', + 'UND_ERR_CONNECT_TIMEOUT', + 'UND_ERR_SOCKET' +]); + +function errorCode(err: unknown): string | undefined { + if (!err || typeof err !== 'object') return undefined; + const { code, cause } = err as { code?: unknown; cause?: unknown }; + if (typeof code === 'string') return code; + if (cause && typeof cause === 'object') { + const causeCode = (cause as { code?: unknown }).code; + if (typeof causeCode === 'string') return causeCode; + } + return undefined; +} + +export function isNetworkError(err: unknown): boolean { + const code = errorCode(err); + if (code !== undefined) return NETWORK_ERROR_CODES.has(code); + if (err instanceof AggregateError) return true; + return err instanceof TypeError && /fetch failed/i.test(err.message); +} + +function extractGraphqlMessages(err: unknown): string[] { + const messages: string[] = []; + const codes: string[] = []; + const visited = new Set(); + const visit = (value: unknown): void => { + if (!value || typeof value !== 'object' || visited.has(value)) return; + visited.add(value); + const record = value as Record; + if (typeof record.message === 'string' && record.message.trim() !== '') { + const message = record.message.trim().replace(/^GraphQL Error:\s*/u, ''); + if (message.length > 0) messages.push(message); + } + const extensions = + record.extensions && typeof record.extensions === 'object' + ? (record.extensions as Record) + : undefined; + if (typeof extensions?.code === 'string') codes.push(extensions.code); + if (Array.isArray(record.errors)) record.errors.forEach(visit); + if (record.cause) visit(record.cause); + if (record.extensions) visit(record.extensions); + if (record.detail) visit(record.detail); + if (record.data) visit(record.data); + if (record.response) visit(record.response); + if (record.result) visit(record.result); + if (record.payload) visit(record.payload); + }; + visit(err); + if (messages.length === 0) { + for (const code of codes) { + if (code === 'UNAUTHENTICATED') messages.push('Your session has expired. Sign in again.'); + if (code === 'FORBIDDEN') { + messages.push('The server rejected this request. Check your account permissions.'); + } + } + } + return [...new Set(messages)]; +} + +export function describeAuthError(err: unknown, endpoint: string): string { + if (isNetworkError(err)) { + return `Could not reach the server at ${endpoint}. Check that it is running, or pick a different backend.`; + } + const graphqlMessages = extractGraphqlMessages(err); + if (graphqlMessages.length > 0) return graphqlMessages.join('; '); + const message = err instanceof Error ? err.message : String(err); + if (message.trim() !== '' && message !== '[object Object]') return message; + const name = err instanceof Error ? err.name : typeof err; + return `Authentication failed unexpectedly (${name}).`; +} + +export const AUTH_TIMEOUT_MS = 10_000; + +export function withAuthTimeout( + promise: Promise, + endpoint: string, + ms: number = AUTH_TIMEOUT_MS +): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject( + new Error( + `The server at ${endpoint} did not respond within ${Math.round(ms / 1000)}s. Check the backend settings and try again.` + ) + ); + }, ms); + promise.then( + value => { + clearTimeout(timer); + resolve(value); + }, + (err: unknown) => { + clearTimeout(timer); + reject(err instanceof Error ? err : new Error(String(err))); + } + ); + }); +} diff --git a/agentic/cli/src/auth.ts b/agentic/cli/src/auth.ts new file mode 100644 index 000000000..2ca0fa1ae --- /dev/null +++ b/agentic/cli/src/auth.ts @@ -0,0 +1,153 @@ +import { auth } from '@constructive-io/sdk'; + +import { + API_KEY_YEARS, + buildCreateApiKeyInput, + classifyApiKeyError, + MintedApiKey, + needsRemint, + parseMintedKey, + remintApiKey +} from './api-key'; +import { AccountSession, clearSession, loadSession, saveSession } from './account-store'; +import { describeAuthError, withAuthTimeout } from './auth-error'; + +type AuthClient = ReturnType; + +interface AuthRecord { + userId?: string; + accessToken?: string; + accessTokenExpiresAt?: string; +} + +export type ApiKeyRefreshStatus = 'ok' | 'reminted' | 'reauth-required' | 'unavailable' | 'signed-out'; + +const SELECT = { + result: { select: { userId: true, accessToken: true, accessTokenExpiresAt: true } } +} as const; + +const CREATE_KEY_SELECT = { + result: { select: { apiKey: true, keyId: true, expiresAt: true } } +} as const; + +const REVOKE_KEY_SELECT = { result: true } as const; + +function authedClient(endpoint: string, bearer: string): AuthClient { + return auth.createClient({ endpoint, headers: { Authorization: `Bearer ${bearer}` } }); +} + +async function mint(client: AuthClient): Promise { + const data = (await client.mutation + .createApiKey({ input: buildCreateApiKeyInput({ years: API_KEY_YEARS }) }, { select: CREATE_KEY_SELECT }) + .unwrap()) as { createApiKey?: { result?: MintedApiKey & { expiresAt?: string } } } | undefined; + return parseMintedKey(data?.createApiKey?.result); +} + +async function revoke(client: AuthClient, keyId: string): Promise { + await client.mutation.revokeApiKey({ input: { keyId } }, { select: REVOKE_KEY_SELECT }).unwrap(); +} + +/** + * Ensure the stored session carries a usable, not-about-to-expire API key. + * Never throws: returns a status the caller can surface. A mint/re-mint needs + * the fresh-password step-up window, so it succeeds right after a sign-in and + * degrades to 'reauth-required' when attempted cold. + */ +export async function refreshApiKeyIfNeeded({ + accountFile, + authEndpoint +}: { + accountFile: string; + authEndpoint: string; +}): Promise { + const session = loadSession(accountFile); + if (!session) return 'signed-out'; + const hasKey = !!session.apiKey; + const due = needsRemint({ apiKeyExpiresAt: session.apiKeyExpiresAt, now: Date.now() }); + if (hasKey && !due) return 'ok'; + + const client = authedClient(authEndpoint, session.accessToken); + try { + const minted = await remintApiKey({ + currentKeyId: session.keyId, + revoke: keyId => revoke(client, keyId), + mint: () => mint(client) + }); + saveSession(accountFile, { + ...session, + apiKey: minted.apiKey, + keyId: minted.keyId, + apiKeyExpiresAt: minted.apiKeyExpiresAt + }); + return 'reminted'; + } catch (err) { + const kind = classifyApiKeyError(err); + if (kind === 'step-up-required' || kind === 'not-authenticated') return 'reauth-required'; + console.warn(`[agent] API key refresh failed (${kind}): ${describeAuthError(err, authEndpoint)}`); + return 'unavailable'; + } +} + +export async function signIn({ + accountFile, + authEndpoint, + email, + password +}: { + accountFile: string; + authEndpoint: string; + email: string; + password: string; +}): Promise { + const trimmedEmail = email.trim(); + if (!trimmedEmail || !password) throw new Error('Email and password are required.'); + + const client = auth.createClient({ endpoint: authEndpoint }); + const request = client.mutation + .signIn({ input: { email: trimmedEmail, password } }, { select: SELECT }) + .unwrap(); + + let data: { signIn?: { result?: AuthRecord } } | undefined; + try { + data = (await withAuthTimeout(request, authEndpoint)) as { signIn?: { result?: AuthRecord } } | undefined; + } catch (err) { + throw new Error(describeAuthError(err, authEndpoint)); + } + + const record = data?.signIn?.result; + if (!record?.accessToken || !record.userId) { + throw new Error('Authentication returned no access token (MFA may be required).'); + } + + saveSession(accountFile, { + userId: record.userId, + email: trimmedEmail, + accessToken: record.accessToken, + ...(record.accessTokenExpiresAt ? { accessTokenExpiresAt: record.accessTokenExpiresAt } : {}), + signedInAt: Date.now() + }); + // Mint the long-lived API key inside the fresh step-up window. Best-effort: a + // mint failure leaves a valid signed-in session that lacks a key until re-auth. + await refreshApiKeyIfNeeded({ accountFile, authEndpoint }); + return loadSession(accountFile); +} + +export async function signOut({ + accountFile, + authEndpoint +}: { + accountFile: string; + authEndpoint: string; +}): Promise { + const session = loadSession(accountFile); + if (!session) return false; + if (session.keyId) { + try { + await revoke(authedClient(authEndpoint, session.accessToken), session.keyId); + } catch (err) { + console.warn(`[agent] API key revoke on sign-out failed: ${describeAuthError(err, authEndpoint)}`); + } + } + clearSession(accountFile); + return true; +} diff --git a/agentic/cli/src/backend-store.ts b/agentic/cli/src/backend-store.ts new file mode 100644 index 000000000..a86df0f29 --- /dev/null +++ b/agentic/cli/src/backend-store.ts @@ -0,0 +1,49 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +export interface BackendConfig { + apiEndpoint: string; + authEndpoint: string; + modulesEndpoint: string; +} + +export const BACKEND_PRESETS: Record = { + localnet: { + apiEndpoint: 'http://api.localhost:3000/graphql', + authEndpoint: 'http://auth.localhost:3000/graphql', + modulesEndpoint: 'http://modules.localhost:3000/graphql' + }, + devnet: { + apiEndpoint: 'https://api.launchql.dev/graphql', + authEndpoint: 'https://auth.launchql.dev/graphql', + modulesEndpoint: 'https://modules.launchql.dev/graphql' + } +}; + +export function loadBackendConfig(file: string): BackendConfig | null { + let raw: string; + try { + raw = fs.readFileSync(file, 'utf8'); + } catch (err: any) { + if (err?.code === 'ENOENT') return null; + throw err; + } + let parsed: BackendConfig; + try { + parsed = JSON.parse(raw) as BackendConfig; + } catch { + return null; + } + if (!parsed?.apiEndpoint || !parsed?.authEndpoint || !parsed?.modulesEndpoint) { + return null; + } + return parsed; +} + +export function saveBackendConfig(file: string, config: BackendConfig): void { + const dir = path.dirname(file); + fs.mkdirSync(dir, { recursive: true }); + const tmp = path.join(dir, `.${path.basename(file)}.tmp`); + fs.writeFileSync(tmp, JSON.stringify(config, null, 2) + '\n'); + fs.renameSync(tmp, file); +} diff --git a/agentic/cli/src/config.ts b/agentic/cli/src/config.ts index 626078a24..783f87a40 100644 --- a/agentic/cli/src/config.ts +++ b/agentic/cli/src/config.ts @@ -17,6 +17,10 @@ export interface AgentCliConfig { overlayDir: string; /** Path of the user-editable manifest: `/skills-manifest.json`. */ manifestFile: string; + /** Signed-in platform session: `/agent/account.json`. */ + accountFile: string; + /** Persisted backend endpoints: `/agent/backend-config.json`. */ + backendFile: string; manifest: SkillsManifest; skillsRepo: string; skillsPin: string; @@ -39,8 +43,12 @@ export function loadConfig(baseDir?: string): AgentCliConfig { const agentDir = path.join(dirs.stash.data, 'agent'); const overlayDir = path.join(dirs.stash.config, 'skills-overlay'); const manifestFile = path.join(dirs.stash.config, 'skills-manifest.json'); + const accountDir = path.join(dirs.stash.config, 'agent'); + const accountFile = path.join(accountDir, 'account.json'); + const backendFile = path.join(accountDir, 'backend-config.json'); fs.mkdirSync(agentDir, { recursive: true }); fs.mkdirSync(overlayDir, { recursive: true }); + fs.mkdirSync(accountDir, { recursive: true }); let file: ManifestFile = {}; if (fs.existsSync(manifestFile)) { @@ -52,6 +60,8 @@ export function loadConfig(baseDir?: string): AgentCliConfig { agentDir, overlayDir, manifestFile, + accountFile, + backendFile, manifest: file.manifest ?? defaultManifest(), skillsRepo: process.env.AGENT_SKILLS_REPO ?? file.repo ?? DEFAULT_SKILLS_REPO, skillsPin: process.env.AGENT_SKILLS_PIN ?? file.pin ?? DEFAULT_SKILLS_PIN diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aaf425a85..3f80d2bbb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -153,6 +153,9 @@ importers: '@agentic-kit/pi': specifier: workspace:* version: link:../pi/dist + '@constructive-io/sdk': + specifier: workspace:* + version: link:../../sdk/constructive-sdk/dist '@earendil-works/pi-coding-agent': specifier: 0.79.6 version: 0.79.6(ws@8.20.1)(zod@4.4.3) @@ -421,7 +424,7 @@ importers: version: 5.2.1 grafserv: specifier: 1.0.0 - version: 1.0.0(@types/node@25.9.1)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5))(ws@8.20.1) + version: 1.0.0(@types/node@22.19.19)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5))(ws@8.20.1) graphile-realtime-subscriptions: specifier: workspace:^ version: link:../graphile-realtime-subscriptions/dist @@ -433,7 +436,7 @@ importers: version: link:../../postgres/pg-cache/dist postgraphile: specifier: 5.0.3 - version: 5.0.3(02c18e7c6179c4ae54031f8cdca16ab5) + version: 5.0.3(f0a861a74cc4311fffaf615b439fe994) devDependencies: '@types/express': specifier: ^5.0.6 @@ -446,7 +449,7 @@ importers: version: 3.1.14 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@25.9.1)(typescript@5.9.3) + version: 10.9.2(@types/node@22.19.19)(typescript@5.9.3) publishDirectory: dist graphile/graphile-connection-filter: @@ -1118,7 +1121,7 @@ importers: version: link:../../postgres/pgsql-test/dist ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@25.9.1)(typescript@5.9.3) + version: 10.9.2(@types/node@22.19.19)(typescript@5.9.3) publishDirectory: dist graphile/graphile-search: @@ -1223,7 +1226,7 @@ importers: version: 1.0.2(graphql@16.13.0) grafserv: specifier: 1.0.0 - version: 1.0.0(@types/node@25.9.1)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5))(ws@8.20.1) + version: 1.0.0(@types/node@22.19.19)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5))(ws@8.20.1) graphile-bucket-provisioner-plugin: specifier: workspace:* version: link:../graphile-bucket-provisioner-plugin/dist @@ -1298,7 +1301,7 @@ importers: version: 5.0.1 postgraphile: specifier: 5.0.3 - version: 5.0.3(02c18e7c6179c4ae54031f8cdca16ab5) + version: 5.0.3(f0a861a74cc4311fffaf615b439fe994) request-ip: specifier: ^3.3.0 version: 3.3.0 @@ -1329,7 +1332,7 @@ importers: version: 3.1.14 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@25.9.1)(typescript@5.9.3) + version: 10.9.2(@types/node@22.19.19)(typescript@5.9.3) publishDirectory: dist graphile/graphile-sql-expression-validator: @@ -1654,7 +1657,7 @@ importers: version: 5.2.1 grafserv: specifier: 1.0.0 - version: 1.0.0(@types/node@25.9.1)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5))(ws@8.20.1) + version: 1.0.0(@types/node@22.19.19)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5))(ws@8.20.1) graphile-cache: specifier: workspace:^ version: link:../../graphile/graphile-cache/dist @@ -1675,7 +1678,7 @@ importers: version: link:../../postgres/pg-env/dist postgraphile: specifier: 5.0.3 - version: 5.0.3(02c18e7c6179c4ae54031f8cdca16ab5) + version: 5.0.3(f0a861a74cc4311fffaf615b439fe994) devDependencies: '@types/express': specifier: ^5.0.6 @@ -1688,7 +1691,7 @@ importers: version: 3.1.14 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@25.9.1)(typescript@5.9.3) + version: 10.9.2(@types/node@22.19.19)(typescript@5.9.3) publishDirectory: dist graphql/gql-ast: @@ -2002,7 +2005,7 @@ importers: version: 1.0.2(graphql@16.13.0) grafserv: specifier: 1.0.0 - version: 1.0.0(@types/node@25.9.1)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5))(ws@8.20.1) + version: 1.0.0(@types/node@22.19.19)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5))(ws@8.20.1) graphile-build: specifier: 5.0.2 version: 5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) @@ -2050,7 +2053,7 @@ importers: version: 5.0.1 postgraphile: specifier: 5.0.3 - version: 5.0.3(02c18e7c6179c4ae54031f8cdca16ab5) + version: 5.0.3(f0a861a74cc4311fffaf615b439fe994) request-ip: specifier: ^3.3.0 version: 3.3.0 @@ -2096,7 +2099,7 @@ importers: version: 7.2.2 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@25.9.1)(typescript@5.9.3) + version: 10.9.2(@types/node@22.19.19)(typescript@5.9.3) publishDirectory: dist graphql/server-test: @@ -2725,7 +2728,7 @@ importers: version: 0.3.0 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@25.9.1)(typescript@5.9.3) + version: 10.9.2(@types/node@22.19.19)(typescript@5.9.3) publishDirectory: dist packages/smtppostmaster: @@ -2754,7 +2757,7 @@ importers: version: 3.18.4 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@25.9.1)(typescript@5.9.3) + version: 10.9.2(@types/node@22.19.19)(typescript@5.9.3) publishDirectory: dist packages/upload-client: @@ -5896,7 +5899,6 @@ packages: engines: { node: '>= 10' } cpu: [arm64] os: [linux] - libc: [glibc] '@mariozechner/clipboard-linux-arm64-musl@0.3.9': resolution: @@ -5906,7 +5908,6 @@ packages: engines: { node: '>= 10' } cpu: [arm64] os: [linux] - libc: [musl] '@mariozechner/clipboard-linux-riscv64-gnu@0.3.9': resolution: @@ -5916,7 +5917,6 @@ packages: engines: { node: '>= 10' } cpu: [riscv64] os: [linux] - libc: [glibc] '@mariozechner/clipboard-linux-x64-gnu@0.3.9': resolution: @@ -5926,7 +5926,6 @@ packages: engines: { node: '>= 10' } cpu: [x64] os: [linux] - libc: [glibc] '@mariozechner/clipboard-linux-x64-musl@0.3.9': resolution: @@ -5936,7 +5935,6 @@ packages: engines: { node: '>= 10' } cpu: [x64] os: [linux] - libc: [musl] '@mariozechner/clipboard-win32-arm64-msvc@0.3.9': resolution: @@ -6182,7 +6180,6 @@ packages: engines: { node: '>= 10' } cpu: [arm64] os: [linux] - libc: [glibc] '@nx/nx-linux-arm64-musl@20.8.3': resolution: @@ -6192,7 +6189,6 @@ packages: engines: { node: '>= 10' } cpu: [arm64] os: [linux] - libc: [musl] '@nx/nx-linux-x64-gnu@20.8.3': resolution: @@ -6202,7 +6198,6 @@ packages: engines: { node: '>= 10' } cpu: [x64] os: [linux] - libc: [glibc] '@nx/nx-linux-x64-musl@20.8.3': resolution: @@ -6212,7 +6207,6 @@ packages: engines: { node: '>= 10' } cpu: [x64] os: [linux] - libc: [musl] '@nx/nx-win32-arm64-msvc@20.8.3': resolution: @@ -6411,7 +6405,6 @@ packages: engines: { node: ^20.19.0 || >=22.12.0 } cpu: [arm64] os: [linux] - libc: [glibc] '@oxfmt/binding-linux-arm64-musl@0.51.0': resolution: @@ -6421,7 +6414,6 @@ packages: engines: { node: ^20.19.0 || >=22.12.0 } cpu: [arm64] os: [linux] - libc: [musl] '@oxfmt/binding-linux-ppc64-gnu@0.51.0': resolution: @@ -6431,7 +6423,6 @@ packages: engines: { node: ^20.19.0 || >=22.12.0 } cpu: [ppc64] os: [linux] - libc: [glibc] '@oxfmt/binding-linux-riscv64-gnu@0.51.0': resolution: @@ -6441,7 +6432,6 @@ packages: engines: { node: ^20.19.0 || >=22.12.0 } cpu: [riscv64] os: [linux] - libc: [glibc] '@oxfmt/binding-linux-riscv64-musl@0.51.0': resolution: @@ -6451,7 +6441,6 @@ packages: engines: { node: ^20.19.0 || >=22.12.0 } cpu: [riscv64] os: [linux] - libc: [musl] '@oxfmt/binding-linux-s390x-gnu@0.51.0': resolution: @@ -6461,7 +6450,6 @@ packages: engines: { node: ^20.19.0 || >=22.12.0 } cpu: [s390x] os: [linux] - libc: [glibc] '@oxfmt/binding-linux-x64-gnu@0.51.0': resolution: @@ -6471,7 +6459,6 @@ packages: engines: { node: ^20.19.0 || >=22.12.0 } cpu: [x64] os: [linux] - libc: [glibc] '@oxfmt/binding-linux-x64-musl@0.51.0': resolution: @@ -6481,7 +6468,6 @@ packages: engines: { node: ^20.19.0 || >=22.12.0 } cpu: [x64] os: [linux] - libc: [musl] '@oxfmt/binding-openharmony-arm64@0.51.0': resolution: @@ -7211,7 +7197,6 @@ packages: } cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.57.1': resolution: @@ -7220,7 +7205,6 @@ packages: } cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.57.1': resolution: @@ -7229,7 +7213,6 @@ packages: } cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.57.1': resolution: @@ -7238,7 +7221,6 @@ packages: } cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.57.1': resolution: @@ -7247,7 +7229,6 @@ packages: } cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.57.1': resolution: @@ -7256,7 +7237,6 @@ packages: } cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.57.1': resolution: @@ -7265,7 +7245,6 @@ packages: } cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.57.1': resolution: @@ -7274,7 +7253,6 @@ packages: } cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.57.1': resolution: @@ -7283,7 +7261,6 @@ packages: } cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.57.1': resolution: @@ -7292,7 +7269,6 @@ packages: } cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.57.1': resolution: @@ -7301,7 +7277,6 @@ packages: } cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.57.1': resolution: @@ -7310,7 +7285,6 @@ packages: } cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.57.1': resolution: @@ -7319,7 +7293,6 @@ packages: } cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.57.1': resolution: @@ -8251,7 +8224,6 @@ packages: } cpu: [arm64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.12.2': resolution: @@ -8260,7 +8232,6 @@ packages: } cpu: [arm64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': resolution: @@ -8269,7 +8240,6 @@ packages: } cpu: [loong64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-loong64-musl@1.12.2': resolution: @@ -8278,7 +8248,6 @@ packages: } cpu: [loong64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': resolution: @@ -8287,7 +8256,6 @@ packages: } cpu: [ppc64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': resolution: @@ -8296,7 +8264,6 @@ packages: } cpu: [riscv64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': resolution: @@ -8305,7 +8272,6 @@ packages: } cpu: [riscv64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': resolution: @@ -8314,7 +8280,6 @@ packages: } cpu: [s390x] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.12.2': resolution: @@ -8323,7 +8288,6 @@ packages: } cpu: [x64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.12.2': resolution: @@ -8332,7 +8296,6 @@ packages: } cpu: [x64] os: [linux] - libc: [musl] '@unrs/resolver-binding-openharmony-arm64@1.12.2': resolution: @@ -9236,6 +9199,7 @@ packages: integrity: sha512-Rvi5pH+LvgsqGwZPZ3Cq/tz4ty7mjijhr3qR4m9IBXNbxGGYgTVVO+duXzz9aArmHxFtwZ+LRkrNIMDQzgoY4A==, } engines: { node: '>=14' } + deprecated: Deprecated and no longer maintained. Please use conventional-changelog instead. conventional-changelog-preset-loader@3.0.0: resolution: @@ -18776,6 +18740,7 @@ snapshots: '@types/node@25.9.1': dependencies: undici-types: 7.24.6 + optional: true '@types/nodemailer@7.0.11': dependencies: @@ -23786,14 +23751,14 @@ snapshots: v8-compile-cache-lib: 3.0.1 yn: 3.1.1 - ts-node@10.9.2(@types/node@25.9.1)(typescript@5.9.3): + ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.12 '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 - '@types/node': 25.9.1 + '@types/node': 22.19.19 acorn: 8.15.0 acorn-walk: 8.3.4 arg: 4.1.3 @@ -23866,7 +23831,8 @@ snapshots: undici-types@6.21.0: {} - undici-types@7.24.6: {} + undici-types@7.24.6: + optional: true undici@8.3.0: {} From 93719c1a4105512bf87bd6e7497938154279be71 Mon Sep 17 00:00:00 2001 From: luca Date: Mon, 3 Aug 2026 22:35:17 +0800 Subject: [PATCH 02/19] feat(pi): export deriveSubdomainEndpoint --- agentic/pi/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/agentic/pi/src/index.ts b/agentic/pi/src/index.ts index d35bb6ad3..a34531f2c 100644 --- a/agentic/pi/src/index.ts +++ b/agentic/pi/src/index.ts @@ -55,6 +55,7 @@ export function createDbTools(host: PiToolsHost): ExtensionFactory { export { type ConfirmGate, type ConfirmGateDeps, createConfirmGate } from './confirm-gate'; export { + deriveSubdomainEndpoint, type ModulesClient, type ProjectContext, resolveDataToken, From 8b404b6251dbc8c09d7d2b6d51a433f0ed3bc4a6 Mon Sep 17 00:00:00 2001 From: luca Date: Mon, 3 Aug 2026 22:35:17 +0800 Subject: [PATCH 03/19] feat(cli): add login, logout and whoami commands --- agentic/cli/README.md | 17 ++- agentic/cli/__tests__/commands.test.ts | 159 +++++++++++++++++++++++++ agentic/cli/src/commands.ts | 114 ++++++++++++++++++ agentic/cli/src/index.ts | 14 ++- 4 files changed, 301 insertions(+), 3 deletions(-) create mode 100644 agentic/cli/__tests__/commands.test.ts diff --git a/agentic/cli/README.md b/agentic/cli/README.md index eac9c12d6..36b8c8dfa 100644 --- a/agentic/cli/README.md +++ b/agentic/cli/README.md @@ -37,6 +37,9 @@ The merged tree is materialized into the appstash-owned agent dir, so the sessio | `agent [options...]` | Start an interactive session with the harness skills | | `agent -p "prompt"` | One-shot print mode | | `agent init` | Configure the skills source (repo + pin) interactively | +| `agent login` | Sign in to the Constructive platform (interactive) | +| `agent logout` | Revoke the API key and clear the stored session | +| `agent whoami` | Show the signed-in account, backend, and masked API key | | `agent skills list` | Resolve and list the effective skill set per layer | | `agent skills update` | Re-fetch the base release and re-materialize | | `agent help` | Usage | @@ -60,12 +63,22 @@ The merged tree is materialized into the appstash-owned agent dir, so the sessio Env overrides: `AGENT_SKILLS_REPO`, `AGENT_SKILLS_PIN`, `AGENT_HOME` (appstash base dir), `GITHUB_TOKEN` (private skills repos). -Db-tool credentials (read per tool call; without them the tools respond "not signed in"): +## Authentication + +`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. + +Credential files (mode `0600`, plaintext): + +- `~/.constructive/config/agent/account.json` — the signed-in session (user, access token, API key) +- `~/.constructive/config/agent/backend-config.json` — the selected backend endpoints + +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. + +Env overrides for CI and headless use (these win over the stored session; read per tool call): `CONSTRUCTIVE_USER_ID`, `CONSTRUCTIVE_ACCESS_TOKEN`, `CONSTRUCTIVE_API_KEY` (optional), `CONSTRUCTIVE_API_ENDPOINT`, `CONSTRUCTIVE_MODULES_ENDPOINT` (default to the local backend). ## Roadmap -- `agent login` — sign in to a Constructive backend and persist credentials in appstash instead of env vars. - Harness system-prompt section + templates/prompts materialization, shared with constructive-desktop via a common adapter package. ## Credits diff --git a/agentic/cli/__tests__/commands.test.ts b/agentic/cli/__tests__/commands.test.ts new file mode 100644 index 000000000..7bfbed426 --- /dev/null +++ b/agentic/cli/__tests__/commands.test.ts @@ -0,0 +1,159 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +jest.mock('../src/auth', () => ({ + signIn: jest.fn(), + signOut: jest.fn(), + refreshApiKeyIfNeeded: jest.fn() +})); + +import { AccountSession, saveSession } from '../src/account-store'; +import { signIn, signOut } from '../src/auth'; +import { BACKEND_PRESETS, loadBackendConfig, saveBackendConfig } from '../src/backend-store'; +import { login, logout, whoami } from '../src/commands'; +import { AgentCliConfig, loadConfig } from '../src/config'; + +const signInMock = signIn as jest.Mock; +const signOutMock = signOut as jest.Mock; + +let home: string; +let config: AgentCliConfig; +let logs: string[]; +let logSpy: jest.SpyInstance; + +const session: AccountSession = { + userId: 'user-1', + email: 'dev@example.com', + accessToken: 'access-token', + apiKey: 'cnc_live_sk_1234567890abcd', + keyId: 'key-1', + apiKeyExpiresAt: '2031-08-03T00:00:00Z', + signedInAt: 1754000000000 +}; + +beforeEach(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-cli-cmd-')); + config = loadConfig(home); + logs = []; + logSpy = jest.spyOn(console, 'log').mockImplementation((msg: string) => { + logs.push(String(msg)); + }); + signInMock.mockReset(); + signOutMock.mockReset(); + process.exitCode = undefined; +}); + +afterEach(() => { + logSpy.mockRestore(); + fs.rmSync(home, { recursive: true, force: true }); + process.exitCode = undefined; +}); + +const output = () => logs.join('\n'); + +describe('login', () => { + it('signs in against a preset backend and persists the backend config', async () => { + signInMock.mockResolvedValue(session); + + await login(config, { backend: 'localnet', email: 'dev@example.com', password: 'pw' }); + + expect(signInMock).toHaveBeenCalledWith({ + accountFile: config.accountFile, + authEndpoint: BACKEND_PRESETS.localnet.authEndpoint, + email: 'dev@example.com', + password: 'pw' + }); + expect(loadBackendConfig(config.backendFile)).toEqual(BACKEND_PRESETS.localnet); + expect(output()).toContain('signed in as dev@example.com'); + expect(output()).toContain('cnc_li...abcd'); + }); + + it('derives auth and modules endpoints from a custom API URL', async () => { + signInMock.mockResolvedValue(session); + + await login(config, { + backend: 'custom', + apiUrl: 'https://api.example.com/graphql', + email: 'dev@example.com', + password: 'pw' + }); + + expect(signInMock).toHaveBeenCalledWith( + expect.objectContaining({ authEndpoint: 'https://auth.example.com/graphql' }) + ); + expect(loadBackendConfig(config.backendFile)).toEqual({ + apiEndpoint: 'https://api.example.com/graphql', + authEndpoint: 'https://auth.example.com/graphql', + modulesEndpoint: 'https://modules.example.com/graphql' + }); + }); + + it('rejects an invalid custom URL before any network call', async () => { + await expect( + login(config, { backend: 'custom', apiUrl: 'not a url', email: 'dev@example.com', password: 'pw' }) + ).rejects.toThrow('Invalid API endpoint URL'); + expect(signInMock).not.toHaveBeenCalled(); + }); + + it('warns when the session lacks an API key after sign-in', async () => { + signInMock.mockResolvedValue({ ...session, apiKey: undefined, keyId: undefined }); + + await login(config, { backend: 'localnet', email: 'dev@example.com', password: 'pw' }); + + expect(output()).toContain('API key mint failed'); + }); + + it('does not persist the backend config when sign-in fails', async () => { + signInMock.mockRejectedValue(new Error('Invalid credentials')); + + await expect( + login(config, { backend: 'devnet', email: 'dev@example.com', password: 'bad' }) + ).rejects.toThrow('Invalid credentials'); + expect(loadBackendConfig(config.backendFile)).toBeNull(); + }); +}); + +describe('logout', () => { + it('signs out against the stored backend', async () => { + saveBackendConfig(config.backendFile, BACKEND_PRESETS.devnet); + signOutMock.mockResolvedValue(true); + + await logout(config); + + expect(signOutMock).toHaveBeenCalledWith({ + accountFile: config.accountFile, + authEndpoint: BACKEND_PRESETS.devnet.authEndpoint + }); + expect(output()).toContain('signed out'); + }); + + it('reports when there is no session', async () => { + signOutMock.mockResolvedValue(false); + await logout(config); + expect(output()).toContain('not signed in'); + }); +}); + +describe('whoami', () => { + it('prints the session details with a masked API key', () => { + saveSession(config.accountFile, session); + saveBackendConfig(config.backendFile, BACKEND_PRESETS.localnet); + + whoami(config); + + expect(process.exitCode).toBeUndefined(); + expect(output()).toContain('signed in as dev@example.com'); + expect(output()).toContain('user id: user-1'); + expect(output()).toContain(BACKEND_PRESETS.localnet.apiEndpoint); + expect(output()).toContain('cnc_li...abcd'); + expect(output()).not.toContain(session.apiKey); + expect(output()).not.toContain('access-token'); + }); + + it('exits 1 with a sign-in hint when signed out', () => { + whoami(config); + expect(process.exitCode).toBe(1); + expect(output()).toContain('run `agent login`'); + }); +}); diff --git a/agentic/cli/src/commands.ts b/agentic/cli/src/commands.ts index c4938a6ff..f751a7a7b 100644 --- a/agentic/cli/src/commands.ts +++ b/agentic/cli/src/commands.ts @@ -1,5 +1,9 @@ +import { deriveSubdomainEndpoint } from '@agentic-kit/pi'; import { Inquirerer } from 'inquirerer'; +import { loadSession } from './account-store'; +import { signIn, signOut } from './auth'; +import { BACKEND_PRESETS, BackendConfig, loadBackendConfig, saveBackendConfig } from './backend-store'; import { AgentCliConfig, defaultManifest, saveManifestFile } from './config'; import { assembleSkills } from './skills'; @@ -45,6 +49,113 @@ export async function init(config: AgentCliConfig, argv: Record log(`local overlay dir (highest precedence): ${config.overlayDir}`); } +function maskKey(token: string): string { + if (token.length <= 10) return '****'; + return `${token.slice(0, 6)}...${token.slice(-4)}`; +} + +function presetNameFor(config: BackendConfig | null): string | undefined { + if (!config) return undefined; + for (const [name, preset] of Object.entries(BACKEND_PRESETS)) { + if (preset.apiEndpoint === config.apiEndpoint) return name; + } + return 'custom'; +} + +export async function login(config: AgentCliConfig, argv: Record): Promise { + if (!process.stdin.isTTY && !(argv.email && argv.password)) { + throw new Error( + 'agent login is interactive and needs a terminal. For headless use, set CONSTRUCTIVE_USER_ID, CONSTRUCTIVE_ACCESS_TOKEN, and CONSTRUCTIVE_API_KEY.' + ); + } + + const saved = loadBackendConfig(config.backendFile); + const prompter = new Inquirerer({ noTty: !process.stdin.isTTY }); + try { + const answers = await prompter.prompt(argv, [ + { + type: 'list', + name: 'backend', + message: 'Backend', + options: [...Object.keys(BACKEND_PRESETS), 'custom'], + default: presetNameFor(saved) ?? 'localnet' + }, + { + type: 'text', + name: 'apiUrl', + message: 'API GraphQL endpoint (e.g. https://api.example.com/graphql)', + default: saved?.apiEndpoint, + when: (a: Record) => a.backend === 'custom' + }, + { type: 'text', name: 'email', message: 'Email' }, + { type: 'password', name: 'password', message: 'Password' } + ]); + + let backend: BackendConfig; + if (answers.backend === 'custom') { + const apiUrl = String(answers.apiUrl ?? '').trim(); + const authEndpoint = deriveSubdomainEndpoint(apiUrl, 'auth'); + const modulesEndpoint = deriveSubdomainEndpoint(apiUrl, 'modules'); + if (!apiUrl || !authEndpoint || !modulesEndpoint) { + throw new Error(`Invalid API endpoint URL: ${apiUrl || '(empty)'}`); + } + backend = { apiEndpoint: apiUrl, authEndpoint, modulesEndpoint }; + } else { + backend = BACKEND_PRESETS[String(answers.backend)]; + if (!backend) throw new Error(`Unknown backend preset: ${answers.backend}`); + } + + const session = await signIn({ + accountFile: config.accountFile, + authEndpoint: backend.authEndpoint, + email: String(answers.email ?? ''), + password: String(answers.password ?? '') + }); + saveBackendConfig(config.backendFile, backend); + + log(`signed in as ${session.email}`); + log(`backend: ${backend.apiEndpoint}`); + if (session.apiKey) { + log(`API key ${maskKey(session.apiKey)} (expires ${session.apiKeyExpiresAt ?? 'unknown'})`); + } else { + log('warning: API key mint failed — db tools stay signed out. Run `agent login` again to retry.'); + } + log(`session stored at ${config.accountFile}`); + } finally { + prompter.close(); + } +} + +export async function logout(config: AgentCliConfig): Promise { + const backend = loadBackendConfig(config.backendFile) ?? BACKEND_PRESETS.localnet; + const wasSignedIn = await signOut({ + accountFile: config.accountFile, + authEndpoint: backend.authEndpoint + }); + if (wasSignedIn) log('signed out — API key revoked and session cleared.'); + else log('not signed in.'); +} + +export function whoami(config: AgentCliConfig): void { + const session = loadSession(config.accountFile); + if (!session) { + log('not signed in — run `agent login`'); + process.exitCode = 1; + return; + } + const backend = loadBackendConfig(config.backendFile); + log(`signed in as ${session.email}`); + log(`user id: ${session.userId}`); + log(`backend: ${backend?.apiEndpoint ?? 'unknown'}`); + if (session.apiKey) { + log(`API key: ${maskKey(session.apiKey)} (expires ${session.apiKeyExpiresAt ?? 'unknown'})`); + } else { + log('API key: none — db tools stay signed out. Run `agent login` to mint one.'); + } + if (session.accessTokenExpiresAt) log(`access token expires: ${session.accessTokenExpiresAt}`); + log(`session file: ${config.accountFile}`); +} + export function usage(): void { console.log(`agent — the pi coding agent with the Constructive harness baked in @@ -52,6 +163,9 @@ Usage: agent [pi options...] start an interactive session (pi TUI) agent -p "prompt" one-shot print mode (pi) agent init configure the skills source (repo + pin) + agent login sign in to the Constructive platform + agent logout revoke the API key and clear the session + agent whoami show the signed-in account agent skills list resolve + list the effective skill set agent skills update re-fetch the base release and re-materialize agent help show this help diff --git a/agentic/cli/src/index.ts b/agentic/cli/src/index.ts index 95415ceb0..b7ee2c974 100644 --- a/agentic/cli/src/index.ts +++ b/agentic/cli/src/index.ts @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { init, skillsList, skillsUpdate, usage } from './commands'; +import { init, login, logout, skillsList, skillsUpdate, usage, whoami } from './commands'; import { loadConfig } from './config'; import { materializeDbTools } from './db-tools'; import { assembleSkills } from './skills'; @@ -23,6 +23,18 @@ async function run(args: string[]): Promise { await init(config, {}); return; } + if (first === 'login') { + await login(config, {}); + return; + } + if (first === 'logout') { + await logout(config); + return; + } + if (first === 'whoami') { + whoami(config); + return; + } if (first === 'skills') { if (second === 'update') await skillsUpdate(config); else await skillsList(config); From a8f9d67f9fd81721e4a3add0363de68bbdd6b793 Mon Sep 17 00:00:00 2001 From: luca Date: Mon, 3 Aug 2026 22:46:17 +0800 Subject: [PATCH 04/19] feat(pi): host-configurable signInHint in signed-out reasons --- agentic/pi/src/context.ts | 19 ++++++++++++------- agentic/pi/src/db-probe.ts | 5 ++++- agentic/pi/src/host.ts | 6 ++++++ .../pi/src/provision-database/credential.ts | 6 ++++-- agentic/pi/src/tools/provision-database.ts | 3 ++- 5 files changed, 28 insertions(+), 11 deletions(-) diff --git a/agentic/pi/src/context.ts b/agentic/pi/src/context.ts index 870486923..3cd697f8e 100644 --- a/agentic/pi/src/context.ts +++ b/agentic/pi/src/context.ts @@ -121,8 +121,9 @@ export async function resolveProjectContext( if (!accountBearer) { return { context: null, - reason: - 'No usable account credential to reach the Constructive control plane. Sign in to the app, then retry.', + reason: `No usable account credential to reach the Constructive control plane. ${ + host.signInHint ?? 'Sign in to the app, then retry.' + }`, code: 'missing-credentials', }; } @@ -137,6 +138,7 @@ export async function resolveProjectContext( endpoint: controlApiEndpoint, bearer: accountBearer, databaseId, + signInHint: host.signInHint, }); if (probe.outcome === 'unreachable') { return { @@ -206,8 +208,11 @@ export async function resolveProjectContext( }; } -const NEEDS_AUTH_REASON = - 'Not signed in to the app database yet. Sign in from the Sheets tab, or open the Preview and sign in to your app, then try again.'; +const needsAuthReason = (): string => + `Not signed in to the app database yet. ${ + getHost().signInHint ?? + 'Sign in from the Sheets tab, or open the Preview and sign in to your app, then try again.' + }`; export type DataTokenResult = { token?: string; userId?: string; reason?: string }; @@ -224,15 +229,15 @@ export async function resolveDataToken(context: ProjectContext): Promise { const executor = args.executor ?? @@ -63,7 +64,9 @@ export async function probeDatabase(args: { if (AUTH_ERROR_RE.test(detail)) { return { outcome: 'unreachable', - detail: `${detail} — the account credential was rejected; sign in again, then retry`, + detail: `${detail} — the account credential was rejected. ${ + args.signInHint ?? 'Sign in again, then retry.' + }`, }; } return { outcome: 'missing' }; diff --git a/agentic/pi/src/host.ts b/agentic/pi/src/host.ts index d8a9156da..7b84eccea 100644 --- a/agentic/pi/src/host.ts +++ b/agentic/pi/src/host.ts @@ -62,6 +62,12 @@ export interface PiToolsHost { backendConfig(): HostBackendConfig | null | undefined; /** Optional data-plane token broker (see DataAuthBroker). */ dataAuthBroker?: DataAuthBroker; + /** + * Host-specific sign-in instruction, substituted into signed-out failure + * reasons (e.g. the CLI's "Run `agent login` to sign in."). Absent hosts get + * the desktop wording. + */ + signInHint?: string; /** Harvest an end-user token from the host's app preview, if it has one. */ previewToken?(): Promise; /** Treat tokens expiring within this window as already expired. Default 30s. */ diff --git a/agentic/pi/src/provision-database/credential.ts b/agentic/pi/src/provision-database/credential.ts index afa2e646d..eeada90c0 100644 --- a/agentic/pi/src/provision-database/credential.ts +++ b/agentic/pi/src/provision-database/credential.ts @@ -20,6 +20,7 @@ export type ProvisionCredential = export function selectProvisionCredential( account: AccountCredential | null | undefined, + signInHint?: string, ): ProvisionCredential { const bearer = account?.apiKey ?? account?.accessToken; if (account?.userId && bearer) { @@ -27,7 +28,8 @@ export function selectProvisionCredential( } return { mode: 'error', - reason: - 'No usable account credential. Sign in (or unlock your session) before provisioning a database.', + reason: `No usable account credential. ${ + signInHint ?? 'Sign in (or unlock your session) before provisioning a database.' + }`, }; } diff --git a/agentic/pi/src/tools/provision-database.ts b/agentic/pi/src/tools/provision-database.ts index e917dc763..57d139d7e 100644 --- a/agentic/pi/src/tools/provision-database.ts +++ b/agentic/pi/src/tools/provision-database.ts @@ -143,7 +143,7 @@ export const provisionDatabaseTool: ToolDefinition< // with no usable bearer errors out here rather than silently minting a // throwaway owner. Gate BEFORE prewarm so an error return doesn't leak a // detached background scaffold/install. - const credential = selectProvisionCredential(host.account()); + const credential = selectProvisionCredential(host.account(), host.signInHint); if (credential.mode === 'error') { return fail(`Cannot provision a database: ${credential.reason}`); } @@ -164,6 +164,7 @@ export const provisionDatabaseTool: ToolDefinition< endpoint: apiEndpoint, bearer: credential.bearer, databaseId: existingEnv.DATABASE_ID, + signInHint: host.signInHint, }); if (probe.outcome === 'unreachable') { return fail( From 9f19ab177cbd9540850bcef2354cab9d5acd0946 Mon Sep 17 00:00:00 2001 From: luca Date: Mon, 3 Aug 2026 22:46:17 +0800 Subject: [PATCH 05/19] feat(cli): db tools read login session, startup key remint --- agentic/cli/__tests__/api-key.test.ts | 4 +- agentic/cli/__tests__/db-tools.test.ts | 105 +++++++++++++++++++++---- agentic/cli/src/auth.ts | 2 +- agentic/cli/src/credentials.ts | 38 +++++++++ agentic/cli/src/db-tools.ts | 32 ++++---- agentic/cli/src/index.ts | 10 +++ 6 files changed, 153 insertions(+), 38 deletions(-) create mode 100644 agentic/cli/src/credentials.ts diff --git a/agentic/cli/__tests__/api-key.test.ts b/agentic/cli/__tests__/api-key.test.ts index ced622b50..5176387b9 100644 --- a/agentic/cli/__tests__/api-key.test.ts +++ b/agentic/cli/__tests__/api-key.test.ts @@ -5,8 +5,8 @@ import { MintedApiKey, needsRemint, parseMintedKey, - remintApiKey, - REMINT_THRESHOLD_MS + REMINT_THRESHOLD_MS, + remintApiKey } from '../src/api-key'; describe('buildCreateApiKeyInput', () => { diff --git a/agentic/cli/__tests__/db-tools.test.ts b/agentic/cli/__tests__/db-tools.test.ts index 99ad5e2d2..10712abdd 100644 --- a/agentic/cli/__tests__/db-tools.test.ts +++ b/agentic/cli/__tests__/db-tools.test.ts @@ -2,17 +2,32 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import { saveSession } from '../src/account-store'; +import { BACKEND_PRESETS, saveBackendConfig } from '../src/backend-store'; import { loadConfig } from '../src/config'; import { materializeDbTools } from '../src/db-tools'; +const CONSTRUCTIVE_VARS = [ + 'CONSTRUCTIVE_USER_ID', + 'CONSTRUCTIVE_ACCESS_TOKEN', + 'CONSTRUCTIVE_API_KEY', + 'CONSTRUCTIVE_API_ENDPOINT', + 'CONSTRUCTIVE_MODULES_ENDPOINT' +]; + describe('materializeDbTools', () => { let home: string; + let prevEnv: NodeJS.ProcessEnv; beforeEach(() => { home = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-cli-dbtools-')); + prevEnv = { ...process.env }; + process.env.AGENT_HOME = home; + for (const name of CONSTRUCTIVE_VARS) delete process.env[name]; }); afterEach(() => { + process.env = prevEnv; fs.rmSync(home, { recursive: true, force: true }); }); @@ -37,29 +52,85 @@ describe('materializeDbTools', () => { expect(registered).toHaveLength(16); }); - it('signals a signed-out host until CONSTRUCTIVE_* env vars are set', () => { + function loadHost() { const config = loadConfig(home); const file = materializeDbTools(config, () => {}); // eslint-disable-next-line @typescript-eslint/no-var-requires require(file!); // eslint-disable-next-line @typescript-eslint/no-var-requires const { getHost } = require('@agentic-kit/pi'); + return { config, host: getHost() }; + } + + it('signals a signed-out host until env vars or a stored session exist', () => { + const { host } = loadHost(); + expect(host.account()).toBeNull(); + expect(host.backendConfig()).toBeUndefined(); + + process.env.CONSTRUCTIVE_USER_ID = 'u1'; + process.env.CONSTRUCTIVE_ACCESS_TOKEN = 't1'; + process.env.CONSTRUCTIVE_API_ENDPOINT = 'http://api.localhost:3000/graphql'; + expect(host.account()).toMatchObject({ userId: 'u1', accessToken: 't1' }); + expect(host.backendConfig()).toMatchObject({ + apiEndpoint: 'http://api.localhost:3000/graphql' + }); + }); + + it('reads the stored session and backend per call when env vars are absent', () => { + const { config, host } = loadHost(); + expect(host.account()).toBeNull(); + + saveSession(config.accountFile, { + userId: 'stored-user', + email: 'dev@example.com', + accessToken: 'stored-token', + apiKey: 'stored-key', + signedInAt: 1 + }); + saveBackendConfig(config.backendFile, BACKEND_PRESETS.devnet); + + expect(host.account()).toEqual({ + userId: 'stored-user', + accessToken: 'stored-token', + apiKey: 'stored-key' + }); + expect(host.backendConfig()).toEqual({ + apiEndpoint: BACKEND_PRESETS.devnet.apiEndpoint, + modulesEndpoint: BACKEND_PRESETS.devnet.modulesEndpoint + }); + }); + + it('lets env vars beat the stored session', () => { + const { config, host } = loadHost(); + saveSession(config.accountFile, { + userId: 'stored-user', + email: 'dev@example.com', + accessToken: 'stored-token', + signedInAt: 1 + }); + + process.env.CONSTRUCTIVE_USER_ID = 'env-user'; + process.env.CONSTRUCTIVE_ACCESS_TOKEN = 'env-token'; + expect(host.account()).toMatchObject({ userId: 'env-user', accessToken: 'env-token' }); + }); + + it('bakes the sign-in hint for pi failure reasons', () => { + const { host } = loadHost(); + expect(host.signInHint).toBe('Run `agent login` to sign in.'); + }); + + it('surfaces `agent login` in the signed-out pi context reason', async () => { + loadHost(); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { resolveProjectContext } = require('@agentic-kit/pi'); + + const project = path.join(home, 'project'); + fs.mkdirSync(project); + fs.writeFileSync(path.join(project, '.env'), 'ACCESS_TOKEN=x\nDATABASE_ID=db1\n'); - const prev = { ...process.env }; - try { - delete process.env.CONSTRUCTIVE_USER_ID; - delete process.env.CONSTRUCTIVE_ACCESS_TOKEN; - expect(getHost().account()).toBeNull(); - - process.env.CONSTRUCTIVE_USER_ID = 'u1'; - process.env.CONSTRUCTIVE_ACCESS_TOKEN = 't1'; - process.env.CONSTRUCTIVE_API_ENDPOINT = 'http://api.localhost:3000/graphql'; - expect(getHost().account()).toMatchObject({ userId: 'u1', accessToken: 't1' }); - expect(getHost().backendConfig()).toMatchObject({ - apiEndpoint: 'http://api.localhost:3000/graphql' - }); - } finally { - process.env = prev; - } + const result = await resolveProjectContext(project); + expect(result.context).toBeNull(); + expect(result.code).toBe('missing-credentials'); + expect(result.reason).toContain('Run `agent login` to sign in.'); }); }); diff --git a/agentic/cli/src/auth.ts b/agentic/cli/src/auth.ts index 2ca0fa1ae..c7f358121 100644 --- a/agentic/cli/src/auth.ts +++ b/agentic/cli/src/auth.ts @@ -1,5 +1,6 @@ import { auth } from '@constructive-io/sdk'; +import { AccountSession, clearSession, loadSession, saveSession } from './account-store'; import { API_KEY_YEARS, buildCreateApiKeyInput, @@ -9,7 +10,6 @@ import { parseMintedKey, remintApiKey } from './api-key'; -import { AccountSession, clearSession, loadSession, saveSession } from './account-store'; import { describeAuthError, withAuthTimeout } from './auth-error'; type AuthClient = ReturnType; diff --git a/agentic/cli/src/credentials.ts b/agentic/cli/src/credentials.ts new file mode 100644 index 000000000..06fd1aa43 --- /dev/null +++ b/agentic/cli/src/credentials.ts @@ -0,0 +1,38 @@ +import { loadSession } from './account-store'; +import { loadBackendConfig } from './backend-store'; +import { loadConfig } from './config'; + +export type ResolvedAccount = { + userId: string; + accessToken: string; + apiKey?: string; +}; + +export type ResolvedBackendConfig = { + apiEndpoint?: string; + modulesEndpoint?: string; +}; + +// Both resolvers run on every tool call and re-read the store, so a login or +// logout mid-session is picked up without a restart. CONSTRUCTIVE_* env vars +// win over the store (the CI/headless path); tokens never enter process.env +// from here. +export function resolveAccount(): ResolvedAccount | null { + const userId = process.env.CONSTRUCTIVE_USER_ID; + const accessToken = process.env.CONSTRUCTIVE_ACCESS_TOKEN; + if (userId && accessToken) { + return { userId, accessToken, apiKey: process.env.CONSTRUCTIVE_API_KEY }; + } + const session = loadSession(loadConfig(process.env.AGENT_HOME).accountFile); + if (!session) return null; + return { userId: session.userId, accessToken: session.accessToken, apiKey: session.apiKey }; +} + +export function resolveBackendConfig(): ResolvedBackendConfig | undefined { + const apiEndpoint = process.env.CONSTRUCTIVE_API_ENDPOINT; + const modulesEndpoint = process.env.CONSTRUCTIVE_MODULES_ENDPOINT; + if (apiEndpoint || modulesEndpoint) return { apiEndpoint, modulesEndpoint }; + const stored = loadBackendConfig(loadConfig(process.env.AGENT_HOME).backendFile); + if (!stored) return undefined; + return { apiEndpoint: stored.apiEndpoint, modulesEndpoint: stored.modulesEndpoint }; +} diff --git a/agentic/cli/src/db-tools.ts b/agentic/cli/src/db-tools.ts index bfa4a5f97..790d13960 100644 --- a/agentic/cli/src/db-tools.ts +++ b/agentic/cli/src/db-tools.ts @@ -6,8 +6,8 @@ import { AgentCliConfig } from './config'; const EXTENSION_FILE = 'constructive-db-tools.js'; /** - * Environment variables the generated extension reads at tool-call time. - * Without CONSTRUCTIVE_ACCESS_TOKEN the tools load but report "not signed in". + * Environment variables that override the stored session at tool-call time. + * Without env vars or a stored session the tools load but report "not signed in". */ export const HOST_ENV_VARS = [ 'CONSTRUCTIVE_USER_ID', @@ -19,26 +19,21 @@ export const HOST_ENV_VARS = [ // pi's TUI loads extensions from files under /extensions (via jiti), // not from factories, so the db tools are wired by materializing a small -// generated entry there. The @agentic-kit/pi entry path is resolved from THIS -// package at assembly time and baked in, because node resolution from the -// appstash agent dir would not find it. -function extensionSource(pkgEntry: string): string { +// generated entry there. Both entry paths are resolved from THIS package at +// assembly time and baked in, because node resolution from the appstash agent +// dir would not find them. +function extensionSource(pkgEntry: string, credentialsEntry: string): string { return `// Generated by @agentic-kit/cli — do not edit (rewritten on every \`agent\` run). // Registers the Constructive typed db tools + confirm gate (@agentic-kit/pi) -// with credentials read from the environment on each call. +// with credentials resolved on each call: CONSTRUCTIVE_* env vars first, then +// the \`agent login\` session store. const { createDbTools } = require(${JSON.stringify(pkgEntry)}); +const { resolveAccount, resolveBackendConfig } = require(${JSON.stringify(credentialsEntry)}); module.exports = createDbTools({ - account: () => { - const userId = process.env.CONSTRUCTIVE_USER_ID; - const accessToken = process.env.CONSTRUCTIVE_ACCESS_TOKEN; - if (!userId || !accessToken) return null; - return { userId, accessToken, apiKey: process.env.CONSTRUCTIVE_API_KEY }; - }, - backendConfig: () => ({ - apiEndpoint: process.env.CONSTRUCTIVE_API_ENDPOINT, - modulesEndpoint: process.env.CONSTRUCTIVE_MODULES_ENDPOINT - }) + account: resolveAccount, + backendConfig: resolveBackendConfig, + signInHint: 'Run \`agent login\` to sign in.' }); `; } @@ -58,9 +53,10 @@ export function materializeDbTools( log('db tools unavailable (@agentic-kit/pi not installed) — continuing without them'); return null; } + const credentialsEntry = require.resolve('./credentials'); const dir = path.join(config.agentDir, 'extensions'); fs.mkdirSync(dir, { recursive: true }); const file = path.join(dir, EXTENSION_FILE); - fs.writeFileSync(file, extensionSource(pkgEntry)); + fs.writeFileSync(file, extensionSource(pkgEntry, credentialsEntry)); return file; } diff --git a/agentic/cli/src/index.ts b/agentic/cli/src/index.ts index b7ee2c974..a400d0459 100644 --- a/agentic/cli/src/index.ts +++ b/agentic/cli/src/index.ts @@ -1,4 +1,6 @@ #!/usr/bin/env node +import { refreshApiKeyIfNeeded } from './auth'; +import { BACKEND_PRESETS, loadBackendConfig } from './backend-store'; import { init, login, logout, skillsList, skillsUpdate, usage, whoami } from './commands'; import { loadConfig } from './config'; import { materializeDbTools } from './db-tools'; @@ -44,6 +46,14 @@ async function run(args: string[]): Promise { const log = (msg: string) => console.log(`[agent] ${msg}`); await assembleSkills(config, log); materializeDbTools(config, log); + // Fire-and-forget: keep the stored API key fresh (<7 days to expiry re-mints) + // without ever blocking startup. Only an expired login session gets a line. + const backend = loadBackendConfig(config.backendFile) ?? BACKEND_PRESETS.localnet; + void refreshApiKeyIfNeeded({ accountFile: config.accountFile, authEndpoint: backend.authEndpoint }) + .then((status) => { + if (status === 'reauth-required') log('API key expired — run `agent login`'); + }) + .catch(() => {}); // pi is ESM-only; tsc's CJS output would downlevel a plain `await import()` // into `require()`, which cannot load it. Indirect the import so it survives // transpilation in the CJS build. From 254802d398edb164d64aaca0038951919da85f2a Mon Sep 17 00:00:00 2001 From: luca Date: Mon, 3 Aug 2026 22:55:01 +0800 Subject: [PATCH 06/19] fix(cli): use workspace:^ for sdk dependency --- agentic/cli/package.json | 2 +- pnpm-lock.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/agentic/cli/package.json b/agentic/cli/package.json index b332ab063..0a719fea3 100644 --- a/agentic/cli/package.json +++ b/agentic/cli/package.json @@ -34,7 +34,7 @@ "dependencies": { "@agentic-kit/harness": "workspace:*", "@agentic-kit/pi": "workspace:*", - "@constructive-io/sdk": "workspace:*", + "@constructive-io/sdk": "workspace:^", "@earendil-works/pi-coding-agent": "0.79.6", "inquirerer": "^4.9.1" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f80d2bbb..dee0db3f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -154,7 +154,7 @@ importers: specifier: workspace:* version: link:../pi/dist '@constructive-io/sdk': - specifier: workspace:* + specifier: workspace:^ version: link:../../sdk/constructive-sdk/dist '@earendil-works/pi-coding-agent': specifier: 0.79.6 From ea21a2040d1faf6d3649367b97eebf3973d852b4 Mon Sep 17 00:00:00 2001 From: luca Date: Mon, 3 Aug 2026 23:09:24 +0800 Subject: [PATCH 07/19] fix(cli): disable inquirerer idle timeout in interactive login --- agentic/cli/src/commands.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/agentic/cli/src/commands.ts b/agentic/cli/src/commands.ts index f751a7a7b..3192ac035 100644 --- a/agentic/cli/src/commands.ts +++ b/agentic/cli/src/commands.ts @@ -70,7 +70,9 @@ export async function login(config: AgentCliConfig, argv: Record Date: Mon, 3 Aug 2026 23:43:39 +0800 Subject: [PATCH 08/19] feat(harness): default constructive-skills source --- .../harness/__tests__/default-source.test.ts | 126 ++++++++++++++++++ agentic/harness/src/index.ts | 1 + agentic/harness/src/skills/default-source.ts | 81 +++++++++++ 3 files changed, 208 insertions(+) create mode 100644 agentic/harness/__tests__/default-source.test.ts create mode 100644 agentic/harness/src/skills/default-source.ts diff --git a/agentic/harness/__tests__/default-source.test.ts b/agentic/harness/__tests__/default-source.test.ts new file mode 100644 index 000000000..814a2a5d9 --- /dev/null +++ b/agentic/harness/__tests__/default-source.test.ts @@ -0,0 +1,126 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as tar from 'tar'; + +import { + DEFAULT_SKILLS_PIN, + DEFAULT_SKILLS_REPO, + DEFAULT_SKILLS_SOURCE_NAME, + defaultSkillLayer, + fetchDefaultSkills, + latestLocalAnyRelease, +} from '../src/skills/default-source'; +import { FetchLike } from '../src/skills/registry'; + +let tmp: string; + +beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-default-source-')); +}); + +afterEach(() => { + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +async function makeRepoTarball(label: string): Promise { + const repoDir = path.join(tmp, `repo-${label}`); + const skillDir = path.join(repoDir, '.agents/skills/alpha'); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync( + path.join(skillDir, 'SKILL.md'), + `---\nname: alpha\ndescription: "alpha ${label}"\n---\n\nbody ${label}\n` + ); + const file = path.join(tmp, `repo-${label}.tgz`); + await tar.create({ gzip: true, file, cwd: repoDir, prefix: `skills-${label}` }, ['.agents']); + return fs.readFileSync(file); +} + +function makeLocalRelease(skillsRoot: string, version: string, body: string): void { + const dir = path.join(skillsRoot, version, '.agents/skills/alpha'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'SKILL.md'), body); +} + +const offline: FetchLike = async () => { + throw new Error('network unreachable'); +}; + +describe('fetchDefaultSkills', () => { + it('fetches DEFAULT_SKILLS_REPO at branch head', async () => { + const data = await makeRepoTarball('main'); + const sha = 'd'.repeat(40); + const requests: string[] = []; + const fetchImpl: FetchLike = async (url) => { + requests.push(url); + if (url.includes('/commits/')) { + return { ok: true, status: 200, json: async () => ({ sha }), arrayBuffer: async () => new ArrayBuffer(0) }; + } + if (url.includes('/tar.gz/')) { + const out = new ArrayBuffer(data.byteLength); + new Uint8Array(out).set(data); + return { ok: true, status: 200, json: async () => ({}), arrayBuffer: async () => out }; + } + return { ok: true, status: 200, json: async () => [], arrayBuffer: async () => new ArrayBuffer(0) }; + }; + + const fetched = await fetchDefaultSkills({ + skillsRoot: path.join(tmp, 'skills'), + fetchImpl, + }); + expect(fetched.version).toBe(sha); + expect(fs.existsSync(path.join(fetched.skillsDir, 'alpha', 'SKILL.md'))).toBe(true); + expect(requests.some((u) => u.includes(DEFAULT_SKILLS_REPO))).toBe(true); + expect(requests.some((u) => u.includes(`/commits/${DEFAULT_SKILLS_PIN}`))).toBe(true); + }); + + it('falls back to a cached branch-head (SHA-named) release when offline', async () => { + const skillsRoot = path.join(tmp, 'skills'); + makeLocalRelease(skillsRoot, 'e'.repeat(40), 'cached head'); + + const fetched = await fetchDefaultSkills({ skillsRoot, fetchImpl: offline }); + expect(fetched.fromCache).toBe(true); + expect(fetched.version).toBe('e'.repeat(40)); + expect(fs.readFileSync(path.join(fetched.skillsDir, 'alpha', 'SKILL.md'), 'utf8')).toBe( + 'cached head' + ); + }); + + it('throws when offline with nothing cached', async () => { + await expect( + fetchDefaultSkills({ skillsRoot: path.join(tmp, 'skills'), fetchImpl: offline }) + ).rejects.toThrow('network unreachable'); + }); +}); + +describe('latestLocalAnyRelease', () => { + it('prefers tagged (semver) releases over SHA directories', () => { + const skillsRoot = path.join(tmp, 'skills'); + makeLocalRelease(skillsRoot, 'f'.repeat(40), 'sha release'); + makeLocalRelease(skillsRoot, '1.2.0', 'tagged release'); + + const release = latestLocalAnyRelease(skillsRoot); + expect(release?.version).toBe('1.2.0'); + }); + + it('picks the newest SHA directory by mtime when no tags exist', () => { + const skillsRoot = path.join(tmp, 'skills'); + makeLocalRelease(skillsRoot, 'a'.repeat(40), 'older'); + makeLocalRelease(skillsRoot, 'b'.repeat(40), 'newer'); + const past = new Date(Date.now() - 60_000); + fs.utimesSync(path.join(skillsRoot, 'a'.repeat(40)), past, past); + + const release = latestLocalAnyRelease(skillsRoot); + expect(release?.version).toBe('b'.repeat(40)); + }); + + it('returns null for a missing or empty root', () => { + expect(latestLocalAnyRelease(path.join(tmp, 'nope'))).toBeNull(); + }); +}); + +describe('defaultSkillLayer', () => { + it('names the default layer', () => { + expect(defaultSkillLayer()).toEqual({ name: DEFAULT_SKILLS_SOURCE_NAME }); + }); +}); diff --git a/agentic/harness/src/index.ts b/agentic/harness/src/index.ts index 31edacaef..5f8dbe164 100644 --- a/agentic/harness/src/index.ts +++ b/agentic/harness/src/index.ts @@ -7,6 +7,7 @@ export * from './gating/confirm-gate'; export * from './gating/decline-guard'; export * from './gating/preview'; export * from './gating/prompts'; +export * from './skills/default-source'; export * from './skills/fetch'; export * from './skills/frontmatter'; export * from './skills/git-fetch'; diff --git a/agentic/harness/src/skills/default-source.ts b/agentic/harness/src/skills/default-source.ts new file mode 100644 index 000000000..2310d9ad3 --- /dev/null +++ b/agentic/harness/src/skills/default-source.ts @@ -0,0 +1,81 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as semver from 'semver'; + +import { FetchedRelease, latestLocalRelease } from './fetch'; +import { fetchSkillsFromGit, GitFetchOptions } from './git-fetch'; +import { SkillSourceRef } from './manifest'; + +/** + * The default skills layer every harness consumer gets without declaring + * anything: the public `constructive-skills` repo at branch head. The repo has + * no semver tags yet, so `main` is the only "latest" mechanism; once tagging + * starts, consumers can harden to a semver pin via their manifest. + */ +export const DEFAULT_SKILLS_REPO = 'constructive-io/constructive-skills'; +export const DEFAULT_SKILLS_SOURCE_NAME = 'constructive-skills'; +export const DEFAULT_SKILLS_PIN = 'main'; + +const DEFAULT_SUBDIR = '.agents/skills'; + +export type FetchDefaultSkillsOptions = Pick & + Partial>; + +/** + * Fetch the default skills layer: `DEFAULT_SKILLS_REPO@main` through the + * normal git fetcher (per-SHA on-disk cache, no re-download for an unchanged + * head). When the fetch fails — offline, rate-limited — the newest release + * already on disk is returned instead; with nothing cached the fetch error + * propagates so the caller can skip the layer with a warning. + */ +export async function fetchDefaultSkills( + options: FetchDefaultSkillsOptions +): Promise { + const subdir = options.repoSubdir ?? DEFAULT_SUBDIR; + try { + return await fetchSkillsFromGit({ + repo: DEFAULT_SKILLS_REPO, + pin: DEFAULT_SKILLS_PIN, + ...options, + }); + } catch (error) { + const local = latestLocalAnyRelease(options.skillsRoot, subdir); + if (local) return local; + throw error; + } +} + +/** Manifest ref for the default layer, lowest precedence by convention. */ +export function defaultSkillLayer(): SkillSourceRef { + return { name: DEFAULT_SKILLS_SOURCE_NAME }; +} + +/** + * Newest already-downloaded release under `skillsRoot`, including branch-head + * fetches. Branch heads unpack under commit-SHA directory names, which the + * semver filter in `latestLocalRelease` cannot see — those fall back to + * newest-by-mtime. Tagged (semver) releases win over SHA directories. + */ +export function latestLocalAnyRelease( + skillsRoot: string, + subdir = DEFAULT_SUBDIR +): FetchedRelease | null { + const tagged = latestLocalRelease(skillsRoot, subdir); + if (tagged) return tagged; + if (!fs.existsSync(skillsRoot)) return null; + + let newest: { version: string; mtimeMs: number } | null = null; + for (const name of fs.readdirSync(skillsRoot)) { + if (semver.valid(name)) continue; + const dir = path.join(skillsRoot, name); + if (!fs.existsSync(path.join(dir, subdir))) continue; + const mtimeMs = fs.statSync(dir).mtimeMs; + if (!newest || mtimeMs > newest.mtimeMs) newest = { version: name, mtimeMs }; + } + if (!newest) return null; + return { + version: newest.version, + skillsDir: path.join(skillsRoot, newest.version, subdir), + fromCache: true, + }; +} From 57791c2914de0698aa8b670e33ad06f56f825776 Mon Sep 17 00:00:00 2001 From: luca Date: Tue, 4 Aug 2026 00:04:53 +0800 Subject: [PATCH 09/19] feat(pi): manage_entity_types tool (list/create/delete) --- agentic/pi/__tests__/host.test.ts | 5 +- .../pi/__tests__/manage-entity-types.test.ts | 155 +++++++++++ agentic/pi/src/index.ts | 2 + agentic/pi/src/tools/manage-entity-types.ts | 251 ++++++++++++++++++ 4 files changed, 411 insertions(+), 2 deletions(-) create mode 100644 agentic/pi/__tests__/manage-entity-types.test.ts create mode 100644 agentic/pi/src/tools/manage-entity-types.ts diff --git a/agentic/pi/__tests__/host.test.ts b/agentic/pi/__tests__/host.test.ts index 0de3d4ff5..a5f679e6b 100644 --- a/agentic/pi/__tests__/host.test.ts +++ b/agentic/pi/__tests__/host.test.ts @@ -45,7 +45,7 @@ describe('toolSchema', () => { }); describe('dbTools extension', () => { - it('registers the 16 tools and both gate events', () => { + it('registers the 17 tools and both gate events', () => { const registered: string[] = []; const events: string[] = []; const fakePi = { @@ -54,7 +54,7 @@ describe('dbTools extension', () => { }; (dbTools as (pi: unknown) => void)(fakePi); - expect(registered).toHaveLength(16); + expect(registered).toHaveLength(17); expect(registered).toEqual( expect.arrayContaining([ 'provision_database', @@ -67,6 +67,7 @@ describe('dbTools extension', () => { 'delete_field', 'add_policies', 'add_records', + 'manage_entity_types', 'run_codegen', 'list_templates', 'create_template', diff --git a/agentic/pi/__tests__/manage-entity-types.test.ts b/agentic/pi/__tests__/manage-entity-types.test.ts new file mode 100644 index 000000000..445508363 --- /dev/null +++ b/agentic/pi/__tests__/manage-entity-types.test.ts @@ -0,0 +1,155 @@ +import type { ExtensionContext } from '@earendil-works/pi-coding-agent'; + +jest.mock('../src/context', () => ({ + resolveProjectContext: jest.fn(), +})); + +import { resolveProjectContext } from '../src/context'; +import { + defaultPrefix, + manageEntityTypesTool, + validateManageEntityTypes, +} from '../src/tools/manage-entity-types'; + +const mockResolve = resolveProjectContext as jest.MockedFunction; + +const ROW = { + id: 'etp-1', + name: 'organization', + description: 'orgs', + prefix: 'org', + parentEntity: null as string | null, + isVisible: true, + outEntityTableName: 'organization', + outInstalledModules: ['membership'], +}; + +function builder(result: unknown) { + return { unwrap: async () => result }; +} + +function makeModules() { + return { + entityTypeProvision: { + findMany: jest.fn().mockReturnValue( + builder({ entityTypeProvisions: { nodes: [ROW], totalCount: 1 } }), + ), + create: jest.fn().mockReturnValue( + builder({ createEntityTypeProvision: { entityTypeProvision: ROW } }), + ), + delete: jest.fn().mockReturnValue( + builder({ deleteEntityTypeProvision: { entityTypeProvision: ROW } }), + ), + }, + }; +} + +function useContext(modules: ReturnType) { + mockResolve.mockResolvedValue({ + context: { modules, databaseId: 'db-1' }, + reason: '', + } as never); +} + +const ctx = { cwd: '/tmp/project' } as unknown as ExtensionContext; + +function run(params: Record) { + return manageEntityTypesTool.execute( + 'tc-1', + params as never, + undefined as never, + undefined as never, + ctx, + ); +} + +afterEach(() => jest.clearAllMocks()); + +describe('validateManageEntityTypes', () => { + it('requires a name for create', () => { + expect(validateManageEntityTypes({ action: 'create' } as never)).toMatch(/requires "name"/); + expect(validateManageEntityTypes({ action: 'create', name: 'org' } as never)).toBeNull(); + }); + + it('rejects an entity_type_id on create', () => { + expect( + validateManageEntityTypes({ action: 'create', name: 'org', entity_type_id: 'etp-1' } as never), + ).toMatch(/does not take "entity_type_id"/); + }); + + it('requires an id for delete and lets list through', () => { + expect(validateManageEntityTypes({ action: 'delete' } as never)).toMatch(/entity_type_id/); + expect(validateManageEntityTypes({ action: 'list' } as never)).toBeNull(); + }); +}); + +describe('defaultPrefix', () => { + it('snake_cases the name', () => { + expect(defaultPrefix('Team Space')).toBe('team_space'); + expect(defaultPrefix(' Déjà-Vu! ')).toBe('d_j_vu'); + }); +}); + +describe('manage_entity_types execute', () => { + it('returns the resolve reason when there is no project context', async () => { + mockResolve.mockResolvedValue({ context: null, reason: 'no project' } as never); + const result = await run({ action: 'list' }); + expect(result.details).toEqual({ success: false, message: 'no project' }); + }); + + it('lists entity types scoped to the project database', async () => { + const modules = makeModules(); + useContext(modules); + const result = await run({ action: 'list' }); + expect(modules.entityTypeProvision.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { databaseId: { equalTo: 'db-1' } } }), + ); + expect(result.details.success).toBe(true); + expect(result.details.entityTypes).toEqual([ + expect.objectContaining({ id: 'etp-1', name: 'organization', entityTableName: 'organization' }), + ]); + }); + + it('creates with the database id and a defaulted snake_case prefix', async () => { + const modules = makeModules(); + useContext(modules); + const result = await run({ action: 'create', name: 'Team Space', has_profiles: true }); + expect(modules.entityTypeProvision.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + databaseId: 'db-1', + name: 'Team Space', + prefix: 'team_space', + hasProfiles: true, + }), + }), + ); + const data = modules.entityTypeProvision.create.mock.calls[0][0].data; + expect(data).not.toHaveProperty('description'); + expect(data).not.toHaveProperty('storage'); + expect(result.details.success).toBe(true); + expect(result.details.message).toMatch(/run_codegen/); + }); + + it('deletes by id and says the entity table remains', async () => { + const modules = makeModules(); + useContext(modules); + const result = await run({ action: 'delete', entity_type_id: 'etp-1' }); + expect(modules.entityTypeProvision.delete).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: 'etp-1' } }), + ); + expect(result.details.message).toMatch(/remains in the API schema/); + }); + + it('surfaces client errors as a failed result', async () => { + const modules = makeModules(); + modules.entityTypeProvision.create.mockReturnValue({ + unwrap: async () => { + throw new Error('duplicate prefix'); + }, + }); + useContext(modules); + const result = await run({ action: 'create', name: 'org' }); + expect(result.details).toEqual({ success: false, message: 'duplicate prefix' }); + }); +}); diff --git a/agentic/pi/src/index.ts b/agentic/pi/src/index.ts index a34531f2c..e75dd4c17 100644 --- a/agentic/pi/src/index.ts +++ b/agentic/pi/src/index.ts @@ -7,6 +7,7 @@ import { addPoliciesTool } from './tools/add-policies'; import { addRecordsTool } from './tools/add-records'; import { addRelationTool } from './tools/add-relation'; import { describeSchemaTool } from './tools/describe-schema'; +import { manageEntityTypesTool } from './tools/manage-entity-types'; import { createFieldTool, deleteFieldTool, deleteTableTool, updateFieldTool } from './tools/mutations'; import { provisionBlueprintTool } from './tools/provision-blueprint'; import { provisionDatabaseTool } from './tools/provision-database'; @@ -36,6 +37,7 @@ export const dbTools: ExtensionFactory = (pi) => { pi.registerTool(updateTemplateTool); pi.registerTool(deleteTemplateTool); pi.registerTool(addRecordsTool); + pi.registerTool(manageEntityTypesTool); pi.registerTool(runCodegenTool); const gate = createConfirmGate({ diff --git a/agentic/pi/src/tools/manage-entity-types.ts b/agentic/pi/src/tools/manage-entity-types.ts new file mode 100644 index 000000000..372e12be7 --- /dev/null +++ b/agentic/pi/src/tools/manage-entity-types.ts @@ -0,0 +1,251 @@ +import type { ToolDefinition } from '@earendil-works/pi-coding-agent'; +import { z } from 'zod'; + +import { resolveProjectContext } from '../context'; +import { toolSchema } from '../tool-schema'; + +const ManageEntityTypesZod = z.object({ + action: z + .enum(['list', 'create', 'delete']) + .describe( + 'list: show the entity types provisioned in this database. create: provision a new entity type (creates its entity table + membership wiring). delete: remove the provision registration. Registrations are immutable once provisioned — there is no update; to change one, delete it and create a new one.', + ), + name: z.string().describe('Entity type name. Required for create.').optional(), + entity_type_id: z + .string() + .describe('Entity type provision id. Required for delete — get it from action "list".') + .optional(), + description: z + .string() + .describe('Human-readable description of the entity type. Create only.') + .optional(), + prefix: z + .string() + .describe( + 'Table-name prefix for the provisioned tables (e.g. "org" → org table, org_memberships). Create only; defaults to the snake_case of name.', + ) + .optional(), + parent_entity: z + .string() + .describe( + 'Parent entity table name, for hierarchical types (e.g. a "team" under an "org"). Create only; the backend defaults it to the root entity (org).', + ) + .optional(), + is_visible: z + .boolean() + .describe('Whether the type shows up in end-user entity listings. Create only.') + .optional(), + has_invites: z + .boolean() + .describe('Provision an invites module ({prefix}_invites + claimed invites). Create only.') + .optional(), + has_profiles: z + .boolean() + .describe('Provision a profiles table for members of this type. Create only.') + .optional(), + has_limits: z + .boolean() + .describe('Provision usage-limits tracking for this type. Create only.') + .optional(), + has_levels: z + .boolean() + .describe('Provision levels/achievements support for this type. Create only.') + .optional(), + has_storage: z + .boolean() + .describe('Provision a storage module (files + buckets tables) for this type. Create only.') + .optional(), +}); +const ManageEntityTypesSchema = toolSchema(ManageEntityTypesZod); + +export type EntityTypeSummary = { + id: string; + name: string | null; + description: string | null; + prefix: string | null; + parentEntity: string | null; + isVisible: boolean | null; + entityTableName: string | null; + installedModules: string[] | null; +}; + +export type ManageEntityTypesDetails = { + success: boolean; + message: string; + entityTypes?: EntityTypeSummary[]; +}; + +const SUMMARY_SELECT = { + id: true, + name: true, + description: true, + prefix: true, + parentEntity: true, + isVisible: true, + outEntityTableName: true, + outInstalledModules: true, +} as const; + +type SummaryRow = { + id: string; + name: string | null; + description: string | null; + prefix: string | null; + parentEntity: string | null; + isVisible: boolean | null; + outEntityTableName: string | null; + outInstalledModules: string[] | null; +}; + +function toSummary(row: SummaryRow): EntityTypeSummary { + return { + id: row.id, + name: row.name, + description: row.description, + prefix: row.prefix, + parentEntity: row.parentEntity, + isVisible: row.isVisible, + entityTableName: row.outEntityTableName, + installedModules: row.outInstalledModules, + }; +} + +function formatSummary(s: EntityTypeSummary): string { + const parts = [ + `- ${s.name ?? '?'} (id: ${s.id})`, + ` table: ${s.entityTableName ?? '?'}, prefix: ${s.prefix ?? '?'}`, + ]; + if (s.parentEntity) parts.push(` parent: ${s.parentEntity}`); + if (s.description) parts.push(` description: ${s.description}`); + if (s.installedModules?.length) parts.push(` modules: ${s.installedModules.join(', ')}`); + return parts.join('\n'); +} + +export function defaultPrefix(name: string): string { + return name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, ''); +} + +type Params = z.infer; + +export function validateManageEntityTypes(params: Params): string | null { + switch (params.action) { + case 'create': + if (!params.name?.trim()) return 'create requires "name".'; + if (params.entity_type_id) return 'create does not take "entity_type_id" — it mints a new one.'; + return null; + case 'delete': + if (!params.entity_type_id) return 'delete requires "entity_type_id" (use action "list" to find it).'; + return null; + default: + return null; + } +} + +export const manageEntityTypesTool: ToolDefinition< + typeof ManageEntityTypesSchema, + ManageEntityTypesDetails +> = { + name: 'manage_entity_types', + label: 'Manage entity types', + description: + 'List, create, or delete entity types in the project database. An entity type (e.g. organization, team, project) provisions its own entity table plus membership wiring, and is the unit that API keys can be scoped to. Registrations are immutable once provisioned — to change one, delete it and create a new one. Deleting removes the registration; the already-provisioned entity table stays in the API schema.', + promptSnippet: + 'manage_entity_types: list/create/delete entity types (orgs, teams, …) in the project database. list is free; create/delete are gated.', + parameters: ManageEntityTypesSchema, + async execute(_id, params: Params, _signal, _onUpdate, ctx) { + const resolved = await resolveProjectContext(ctx.cwd); + if (!resolved.context) { + return { + content: [{ type: 'text', text: resolved.reason }], + details: { success: false, message: resolved.reason }, + }; + } + + const invalid = validateManageEntityTypes(params); + if (invalid) { + return { + content: [{ type: 'text', text: invalid }], + details: { success: false, message: invalid }, + }; + } + + const { modules, databaseId } = resolved.context; + + try { + switch (params.action) { + case 'list': { + const result = await modules.entityTypeProvision + .findMany({ + where: { databaseId: { equalTo: databaseId } }, + select: SUMMARY_SELECT, + }) + .unwrap(); + const entityTypes = result.entityTypeProvisions.nodes.map((row) => + toSummary(row as SummaryRow), + ); + const message = + entityTypes.length === 0 + ? 'No entity types are provisioned in this database yet.' + : `${entityTypes.length} entity type${entityTypes.length === 1 ? '' : 's'}:\n${entityTypes.map(formatSummary).join('\n')}`; + return { + content: [{ type: 'text', text: message }], + details: { success: true, message, entityTypes }, + }; + } + case 'create': { + const name = params.name!.trim(); + const result = await modules.entityTypeProvision + .create({ + data: { + databaseId, + name, + prefix: params.prefix?.trim() || defaultPrefix(name), + ...(params.description !== undefined && { description: params.description }), + ...(params.parent_entity !== undefined && { parentEntity: params.parent_entity }), + ...(params.is_visible !== undefined && { isVisible: params.is_visible }), + ...(params.has_invites !== undefined && { hasInvites: params.has_invites }), + ...(params.has_profiles !== undefined && { hasProfiles: params.has_profiles }), + ...(params.has_limits !== undefined && { hasLimits: params.has_limits }), + ...(params.has_levels !== undefined && { hasLevels: params.has_levels }), + ...(params.has_storage && { + storage: [{}] as unknown as Record, + }), + }, + select: SUMMARY_SELECT, + }) + .unwrap(); + const row = result.createEntityTypeProvision?.entityTypeProvision; + if (!row) throw new Error('createEntityTypeProvision returned no row.'); + const summary = toSummary(row as SummaryRow); + const message = `Created entity type "${summary.name}" (id: ${summary.id}) — entity table "${summary.entityTableName}"${summary.installedModules?.length ? `, modules: ${summary.installedModules.join(', ')}` : ''}. Run run_codegen to pull it into the typed SDK.`; + return { + content: [{ type: 'text', text: message }], + details: { success: true, message, entityTypes: [summary] }, + }; + } + case 'delete': { + const result = await modules.entityTypeProvision + .delete({ + where: { id: params.entity_type_id! }, + select: { id: true, name: true, outEntityTableName: true }, + }) + .unwrap(); + const row = result.deleteEntityTypeProvision?.entityTypeProvision; + const name = row?.name ?? params.entity_type_id; + const message = `Deleted entity type registration "${name}". Note: the provisioned entity table${row?.outEntityTableName ? ` "${row.outEntityTableName}"` : ''} remains in the API schema with its data.`; + return { + content: [{ type: 'text', text: message }], + details: { success: true, message }, + }; + } + } + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to manage entity types'; + return { content: [{ type: 'text', text: message }], details: { success: false, message } }; + } + }, +}; From 9470663d094088f786fd45c556cedfa90b905d19 Mon Sep 17 00:00:00 2001 From: luca Date: Tue, 4 Aug 2026 00:04:53 +0800 Subject: [PATCH 10/19] feat(harness): confirm-gate manage_entity_types mutations --- agentic/harness/__tests__/gating.test.ts | 35 ++++++++++++++++++++++ agentic/harness/src/gating/confirm-gate.ts | 4 +++ agentic/harness/src/gating/prompts.ts | 20 +++++++++++++ 3 files changed, 59 insertions(+) diff --git a/agentic/harness/__tests__/gating.test.ts b/agentic/harness/__tests__/gating.test.ts index d6d5aebee..832339461 100644 --- a/agentic/harness/__tests__/gating.test.ts +++ b/agentic/harness/__tests__/gating.test.ts @@ -170,6 +170,28 @@ describe('confirm gate: declines are respected', () => { expect(result).toBeUndefined(); expect(confirmCalls()).toBe(0); }); + + it('lets manage_entity_types list pass without a prompt', async () => { + const { gate, host, confirmCalls } = createHarness({ isProjectRunnable: async () => true }); + const result = await gate.onToolCall( + call('manage_entity_types', 'tc-1', { action: 'list' }), + host, + CWD + ); + expect(result).toBeUndefined(); + expect(confirmCalls()).toBe(0); + }); + + it('gates manage_entity_types mutations', async () => { + const { gate, host, confirmCalls } = createHarness({ isProjectRunnable: async () => true }); + const result = await gate.onToolCall( + call('manage_entity_types', 'tc-1', { action: 'create', name: 'org' }), + host, + CWD + ); + expect(result?.block).toBe(true); + expect(confirmCalls()).toBe(1); + }); }); describe('decline guard canonicalization', () => { @@ -217,4 +239,17 @@ describe('buildConfirmPrompt', () => { const prompt = buildConfirmPrompt('mystery_tool', {}); expect(prompt.message).toContain('mystery_tool'); }); + + it('builds action-specific prompts for manage_entity_types', () => { + const create = buildConfirmPrompt('manage_entity_types', { action: 'create', name: 'org' }); + expect(create.title).toBe('Create entity type?'); + expect(create.message).toContain('"org"'); + + const remove = buildConfirmPrompt('manage_entity_types', { + action: 'delete', + entity_type_id: 'etp-1', + }); + expect(remove.title).toBe('Delete entity type?'); + expect(remove.message).toMatch(/stay in the API schema/); + }); }); diff --git a/agentic/harness/src/gating/confirm-gate.ts b/agentic/harness/src/gating/confirm-gate.ts index 6abdc453f..c086a5067 100644 --- a/agentic/harness/src/gating/confirm-gate.ts +++ b/agentic/harness/src/gating/confirm-gate.ts @@ -93,6 +93,10 @@ export function createConfirmGate(deps: ConfirmGateDeps): ConfirmGate { const input = event.input; + // manage_entity_types multiplexes read + write actions behind one tool + // name; its read action is not a mutation, so it skips the gate. + if (event.toolName === 'manage_entity_types' && input?.action === 'list') return; + const retryBlock = declineGuard.checkRetry(event.toolName, input); if (retryBlock) { if (host.hasUI) { diff --git a/agentic/harness/src/gating/prompts.ts b/agentic/harness/src/gating/prompts.ts index 0e951db9d..df43eabd0 100644 --- a/agentic/harness/src/gating/prompts.ts +++ b/agentic/harness/src/gating/prompts.ts @@ -12,6 +12,7 @@ export const MUTATING_DB_TOOLS = new Set([ 'update_template', 'delete_template', 'add_records', + 'manage_entity_types', 'run_codegen', ]); @@ -181,6 +182,25 @@ export function buildConfirmPrompt( preview: count > 0 ? { kind: 'records', tableName, rows } : undefined, }; } + case 'manage_entity_types': { + const action = str(input, 'action'); + const name = str(input, 'name'); + const id = str(input, 'entity_type_id'); + switch (action) { + case 'create': + return { + title: 'Create entity type?', + message: `Provision entity type "${name ?? '?'}" in the project database (creates its entity table and membership wiring).`, + }; + case 'delete': + return { + title: 'Delete entity type?', + message: `Delete the registration of entity type ${id ?? '?'}. The provisioned entity table and its data stay in the API schema.`, + }; + default: + return { title: 'Manage entity types?', message: 'Change entity types in the project database.' }; + } + } case 'run_codegen': return { title: 'Run codegen?', From 8acfcd849118952929ce1a4200576d370a57d4c1 Mon Sep 17 00:00:00 2001 From: luca Date: Tue, 4 Aug 2026 00:17:46 +0800 Subject: [PATCH 11/19] feat(pi): create_api_key tool + secret-delivery host hooks --- agentic/pi/__tests__/create-api-key.test.ts | 277 ++++++++++++++++++++ agentic/pi/__tests__/host.test.ts | 5 +- agentic/pi/src/host.ts | 24 ++ agentic/pi/src/index.ts | 2 + agentic/pi/src/tools/create-api-key.ts | 265 +++++++++++++++++++ 5 files changed, 571 insertions(+), 2 deletions(-) create mode 100644 agentic/pi/__tests__/create-api-key.test.ts create mode 100644 agentic/pi/src/tools/create-api-key.ts diff --git a/agentic/pi/__tests__/create-api-key.test.ts b/agentic/pi/__tests__/create-api-key.test.ts new file mode 100644 index 000000000..7e1af8e96 --- /dev/null +++ b/agentic/pi/__tests__/create-api-key.test.ts @@ -0,0 +1,277 @@ +import type { ExtensionContext } from '@earendil-works/pi-coding-agent'; + +jest.mock('../src/context', () => ({ + resolveProjectContext: jest.fn(), + resolveDataToken: jest.fn(), + deriveSubdomainEndpoint: jest.fn(() => 'http://auth-demo.localhost:6464/graphql'), +})); +jest.mock('../src/host', () => ({ getHost: jest.fn() })); +jest.mock('@constructive-io/sdk', () => ({ auth: { createClient: jest.fn() } })); + +import { auth } from '@constructive-io/sdk'; + +import { resolveDataToken, resolveProjectContext } from '../src/context'; +import { getHost } from '../src/host'; +import { + createApiKeyTool, + describeScope, + isStepUpError, + toEnvVar, +} from '../src/tools/create-api-key'; + +const mockResolve = resolveProjectContext as jest.MockedFunction; +const mockToken = resolveDataToken as jest.MockedFunction; +const mockGetHost = getHost as jest.MockedFunction; +const mockCreateClient = auth.createClient as jest.Mock; + +const PLAINTEXT = 'cnc_live_sk_super_secret_value'; +const MINTED = { + ok: true, + data: { createApiKey: { result: { apiKey: PLAINTEXT, keyId: 'key-1', expiresAt: '2026-11-01' } } }, + errors: [] as { message: string }[], +}; +const STEP_UP = { + ok: false, + data: undefined as unknown, + errors: [{ message: 'STEP_UP_REQUIRED: verify your password' }], +}; + +function makeClient(mintResults: unknown[], existingPrincipal: unknown = null) { + const createApiKey = jest.fn(); + for (const result of mintResults) { + createApiKey.mockReturnValueOnce({ execute: async (): Promise => result }); + } + return { + principal: { + findFirst: jest.fn().mockReturnValue({ + unwrap: async (): Promise => ({ principal: existingPrincipal }), + }), + }, + mutation: { createApiKey }, + }; +} + +function makeHost(overrides: Record = {}) { + return { + account: (): null => null, + backendConfig: (): null => null, + deliverSecret: jest.fn(async (): Promise => undefined), + requestStepUp: jest.fn(async (): Promise => true), + ...overrides, + }; +} + +function mockFetchWith(entityIdsSupported: boolean) { + return jest.fn(async (_url: unknown, init: { body?: string } | undefined) => { + const body = String(init?.body ?? ''); + const json = body.includes('__type') + ? { + data: { + __type: { + inputFields: [ + { name: 'name' }, + ...(entityIdsSupported ? [{ name: 'entityIds' }, { name: 'isReadOnly' }] : []), + ], + }, + }, + } + : { data: { createPrincipal: { result: 'prin-1' } } }; + return { json: async () => json }; + }); +} + +const ctx = { cwd: '/tmp/project' } as unknown as ExtensionContext; + +function run(params: Record) { + return createApiKeyTool.execute( + 'tc-1', + params as never, + undefined as never, + undefined as never, + ctx, + ); +} + +function useContext() { + mockResolve.mockResolvedValue({ + context: { + databaseId: 'db-1', + databaseName: 'demo', + apiEndpoint: 'http://api.localhost:6464/graphql', + }, + reason: '', + } as never); +} + +const realFetch = global.fetch; + +afterEach(() => { + jest.clearAllMocks(); + global.fetch = realFetch; +}); + +describe('helpers', () => { + it('derives the env var name', () => { + expect(toEnvVar('deploy bot')).toBe('DEPLOY_BOT_API_KEY'); + expect(toEnvVar(' CI API key ')).toBe('CI_API_KEY'); + }); + + it('detects step-up errors', () => { + expect(isStepUpError(['STEP_UP_REQUIRED: verify'])).toBe(true); + expect(isStepUpError(['Step-up required'])).toBe(true); + expect(isStepUpError(['permission denied'])).toBe(false); + }); + + it('summarizes scope', () => { + expect(describeScope(['a'], true)).toBe('scoped to 1 entity, read-only'); + expect(describeScope(undefined, false)).toMatch(/unscoped/); + }); +}); + +describe('create_api_key execute', () => { + it('refuses before any client call when the host cannot deliver secrets', async () => { + useContext(); + mockGetHost.mockReturnValue(makeHost({ deliverSecret: undefined }) as never); + const result = await run({ key_name: 'deploy bot' }); + expect(result.details.success).toBe(false); + expect(result.details.message).toMatch(/cannot deliver secrets/); + expect(mockToken).not.toHaveBeenCalled(); + expect(mockCreateClient).not.toHaveBeenCalled(); + }); + + it('returns the needs-auth prompt without a data token', async () => { + useContext(); + mockGetHost.mockReturnValue(makeHost() as never); + mockToken.mockResolvedValue({ reason: 'Not signed in to the app database yet.' }); + const result = await run({ key_name: 'deploy bot' }); + expect(result.details).toMatchObject({ success: false, needsAuth: true }); + expect(mockCreateClient).not.toHaveBeenCalled(); + }); + + it('fails explicit when scoping is requested but the surface is absent', async () => { + useContext(); + mockGetHost.mockReturnValue(makeHost() as never); + mockToken.mockResolvedValue({ token: 'tok', userId: 'u1' }); + const fetchMock = mockFetchWith(false); + global.fetch = fetchMock as never; + const client = makeClient([MINTED]); + mockCreateClient.mockReturnValue(client); + + const result = await run({ key_name: 'scoped key', entity_ids: ['e-1'] }); + expect(result.details.success).toBe(false); + expect(result.details.message).toMatch(/does not support scoping/); + expect(result.details.message).toMatch(/No key was minted/); + expect(client.mutation.createApiKey).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('refuses to reuse an existing principal for a scoped key', async () => { + useContext(); + mockGetHost.mockReturnValue(makeHost() as never); + mockToken.mockResolvedValue({ token: 'tok' }); + global.fetch = mockFetchWith(true) as never; + const client = makeClient([MINTED], { id: 'prin-old', name: 'bot' }); + mockCreateClient.mockReturnValue(client); + + const result = await run({ key_name: 'k', principal_name: 'bot', entity_ids: ['e-1'] }); + expect(result.details.success).toBe(false); + expect(result.details.message).toMatch(/already exists/); + expect(client.mutation.createApiKey).not.toHaveBeenCalled(); + }); + + it('mints, delivers the secret out of band, and keeps it out of the result', async () => { + useContext(); + const host = makeHost(); + mockGetHost.mockReturnValue(host as never); + mockToken.mockResolvedValue({ token: 'tok', userId: 'u1' }); + const fetchMock = mockFetchWith(true); + global.fetch = fetchMock as never; + const client = makeClient([MINTED]); + mockCreateClient.mockReturnValue(client); + + const result = await run({ key_name: 'deploy bot', expires_in_days: 30 }); + expect(result.details).toMatchObject({ + success: true, + keyId: 'key-1', + envVar: 'DEPLOY_BOT_API_KEY', + expiresAt: '2026-11-01', + principalId: 'prin-1', + principalName: 'deploy bot', + }); + expect(host.deliverSecret).toHaveBeenCalledWith({ + databaseId: 'db-1', + envVar: 'DEPLOY_BOT_API_KEY', + plaintext: PLAINTEXT, + keyId: 'key-1', + expiresAt: '2026-11-01', + }); + expect(JSON.stringify(result)).not.toContain(PLAINTEXT); + const mintInput = client.mutation.createApiKey.mock.calls[0][0].input; + expect(mintInput).toMatchObject({ principalId: 'prin-1', keyName: 'deploy bot', expiresIn: { days: 30 } }); + expect(mintInput).not.toHaveProperty('accessLevel'); + }); + + it('retries exactly once after a successful step-up', async () => { + useContext(); + const host = makeHost(); + mockGetHost.mockReturnValue(host as never); + mockToken.mockResolvedValue({ token: 'tok' }); + global.fetch = mockFetchWith(true) as never; + const client = makeClient([STEP_UP, MINTED]); + mockCreateClient.mockReturnValue(client); + + const result = await run({ key_name: 'deploy bot' }); + expect(result.details.success).toBe(true); + expect(host.requestStepUp).toHaveBeenCalledTimes(1); + expect(host.requestStepUp).toHaveBeenCalledWith('db-1'); + expect(client.mutation.createApiKey).toHaveBeenCalledTimes(2); + expect(JSON.stringify(result)).not.toContain(PLAINTEXT); + }); + + it('instructs instead of retrying when the host lacks the step-up hook', async () => { + useContext(); + mockGetHost.mockReturnValue(makeHost({ requestStepUp: undefined }) as never); + mockToken.mockResolvedValue({ token: 'tok' }); + global.fetch = mockFetchWith(true) as never; + const client = makeClient([STEP_UP]); + mockCreateClient.mockReturnValue(client); + + const result = await run({ key_name: 'deploy bot' }); + expect(result.details.success).toBe(false); + expect(result.details.message).toMatch(/step-up/i); + expect(client.mutation.createApiKey).toHaveBeenCalledTimes(1); + }); + + it('fails without minting when the user declines step-up', async () => { + useContext(); + const host = makeHost({ requestStepUp: jest.fn(async () => false) }); + mockGetHost.mockReturnValue(host as never); + mockToken.mockResolvedValue({ token: 'tok' }); + global.fetch = mockFetchWith(true) as never; + const client = makeClient([STEP_UP, MINTED]); + mockCreateClient.mockReturnValue(client); + + const result = await run({ key_name: 'deploy bot' }); + expect(result.details.success).toBe(false); + expect(result.details.message).toMatch(/not completed/); + expect(client.mutation.createApiKey).toHaveBeenCalledTimes(1); + expect(host.deliverSecret).not.toHaveBeenCalled(); + }); + + it('keeps the plaintext out of failure results too', async () => { + useContext(); + const host = makeHost({ + deliverSecret: jest.fn(async () => { + throw new Error('env write failed'); + }), + }); + mockGetHost.mockReturnValue(host as never); + mockToken.mockResolvedValue({ token: 'tok' }); + global.fetch = mockFetchWith(true) as never; + mockCreateClient.mockReturnValue(makeClient([MINTED])); + + const result = await run({ key_name: 'deploy bot' }); + expect(result.details.success).toBe(false); + expect(JSON.stringify(result)).not.toContain(PLAINTEXT); + }); +}); diff --git a/agentic/pi/__tests__/host.test.ts b/agentic/pi/__tests__/host.test.ts index a5f679e6b..75d4b492e 100644 --- a/agentic/pi/__tests__/host.test.ts +++ b/agentic/pi/__tests__/host.test.ts @@ -45,7 +45,7 @@ describe('toolSchema', () => { }); describe('dbTools extension', () => { - it('registers the 17 tools and both gate events', () => { + it('registers the 18 tools and both gate events', () => { const registered: string[] = []; const events: string[] = []; const fakePi = { @@ -54,7 +54,7 @@ describe('dbTools extension', () => { }; (dbTools as (pi: unknown) => void)(fakePi); - expect(registered).toHaveLength(17); + expect(registered).toHaveLength(18); expect(registered).toEqual( expect.arrayContaining([ 'provision_database', @@ -68,6 +68,7 @@ describe('dbTools extension', () => { 'add_policies', 'add_records', 'manage_entity_types', + 'create_api_key', 'run_codegen', 'list_templates', 'create_template', diff --git a/agentic/pi/src/host.ts b/agentic/pi/src/host.ts index 7b84eccea..ec54f69d6 100644 --- a/agentic/pi/src/host.ts +++ b/agentic/pi/src/host.ts @@ -55,6 +55,19 @@ export interface HostProvisionOverlay { remove?: string[]; } +/** + * A minted secret handed to the host for out-of-band delivery (.env write + + * one-time reveal). The plaintext never enters tool results or the transcript; + * pi forgets it after this call. + */ +export type SecretDelivery = { + databaseId: string; + envVar: string; + plaintext: string; + keyId: string; + expiresAt?: string; +}; + export interface PiToolsHost { /** Signed-in platform account, or null/undefined when signed out. */ account(): HostAccount | null | undefined; @@ -83,6 +96,17 @@ export interface PiToolsHost { | null | undefined | Promise; + /** + * Complete MFA step-up for the database's app session in the host's own + * process (password dialog + verifyPassword). The password never passes + * through pi or the model. Resolve true when step-up succeeded. + */ + requestStepUp?(databaseId: string): Promise; + /** + * Deliver a minted secret to the user (.env write + one-time reveal). + * Required for create_api_key — without it the tool refuses to mint. + */ + deliverSecret?(delivery: SecretDelivery): Promise; } export const DEFAULT_DATA_TOKEN_SKEW_MS = 30_000; diff --git a/agentic/pi/src/index.ts b/agentic/pi/src/index.ts index e75dd4c17..b7d02bf7c 100644 --- a/agentic/pi/src/index.ts +++ b/agentic/pi/src/index.ts @@ -6,6 +6,7 @@ import { configureHost, type PiToolsHost } from './host'; import { addPoliciesTool } from './tools/add-policies'; import { addRecordsTool } from './tools/add-records'; import { addRelationTool } from './tools/add-relation'; +import { createApiKeyTool } from './tools/create-api-key'; import { describeSchemaTool } from './tools/describe-schema'; import { manageEntityTypesTool } from './tools/manage-entity-types'; import { createFieldTool, deleteFieldTool, deleteTableTool, updateFieldTool } from './tools/mutations'; @@ -38,6 +39,7 @@ export const dbTools: ExtensionFactory = (pi) => { pi.registerTool(deleteTemplateTool); pi.registerTool(addRecordsTool); pi.registerTool(manageEntityTypesTool); + pi.registerTool(createApiKeyTool); pi.registerTool(runCodegenTool); const gate = createConfirmGate({ diff --git a/agentic/pi/src/tools/create-api-key.ts b/agentic/pi/src/tools/create-api-key.ts new file mode 100644 index 000000000..d7d7a11fe --- /dev/null +++ b/agentic/pi/src/tools/create-api-key.ts @@ -0,0 +1,265 @@ +import { auth } from '@constructive-io/sdk'; +import type { ToolDefinition } from '@earendil-works/pi-coding-agent'; +import { z } from 'zod'; + +import { deriveSubdomainEndpoint, resolveDataToken, resolveProjectContext } from '../context'; +import { getHost } from '../host'; +import { toolSchema } from '../tool-schema'; + +const CreateApiKeyZod = z.object({ + key_name: z + .string() + .describe( + 'Name for the API key (e.g. "deploy bot"). The .env variable name derives from it (UPPER_SNAKE + _API_KEY).', + ), + principal_name: z + .string() + .describe( + 'Machine identity (principal) that owns the key. Defaults to key_name. An existing principal with this name is reused for unscoped keys.', + ) + .optional(), + entity_ids: z + .array(z.string()) + .describe( + 'Entity row UUIDs to scope the principal to. Omit for a personal (unscoped) key that acts as the signed-in app user.', + ) + .optional(), + read_only: z + .boolean() + .describe('Restrict the principal and key to read-only operations.') + .optional(), + expires_in_days: z + .number() + .int() + .positive() + .describe('Key lifetime in days. Default 90.') + .optional(), +}); +const CreateApiKeySchema = toolSchema(CreateApiKeyZod); + +export type CreateApiKeyDetails = { + success: boolean; + message: string; + keyId?: string; + envVar?: string; + expiresAt?: string; + principalId?: string; + principalName?: string; + scope?: string; + needsAuth?: boolean; +}; + +type ToolResult = { content: { type: 'text'; text: string }[]; details: CreateApiKeyDetails }; + +function fail(message: string, extra?: Partial): ToolResult { + return { + content: [{ type: 'text', text: message }], + details: { success: false, message, ...extra }, + }; +} + +export function toEnvVar(keyName: string): string { + const base = keyName + .trim() + .toUpperCase() + .replace(/[^A-Z0-9]+/g, '_') + .replace(/^_+|_+$/g, ''); + return base.endsWith('_API_KEY') ? base : `${base}_API_KEY`; +} + +export function isStepUpError(messages: string[]): boolean { + return messages.some((m) => /step[\s_-]?up/i.test(m)); +} + +export function describeScope(entityIds: string[] | undefined, readOnly: boolean): string { + const parts: string[] = []; + parts.push( + entityIds?.length + ? `scoped to ${entityIds.length} entit${entityIds.length === 1 ? 'y' : 'ies'}` + : 'unscoped (acts as the signed-in app user)', + ); + if (readOnly) parts.push('read-only'); + return parts.join(', '); +} + +async function rawGraphql( + endpoint: string, + token: string | undefined, + query: string, + variables?: Record, +): Promise<{ data?: Record; errors?: { message: string }[] }> { + const res = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ query, variables }), + }); + return (await res.json()) as { data?: Record; errors?: { message: string }[] }; +} + +async function supportsCreateTimeScoping(endpoint: string): Promise { + const probe = await rawGraphql( + endpoint, + undefined, + '{ __type(name: "CreatePrincipalInput") { inputFields { name } } }', + ); + const type = probe.data?.__type as { inputFields?: { name: string }[] } | null | undefined; + return Boolean(type?.inputFields?.some((f) => f.name === 'entityIds')); +} + +type Params = z.infer; + +export const createApiKeyTool: ToolDefinition = { + name: 'create_api_key', + label: 'Create API key', + description: + 'Mint an API key for the project database, owned by a machine principal. Optionally scope it to specific entity rows and/or read-only. The human confirms the mint, completes MFA step-up in the app when required, and receives the key via .env + a one-time reveal — the plaintext key never appears in this conversation. Requires the user to be signed in to their app in the Preview.', + promptSnippet: + 'create_api_key: mint an entity-scoped API key for the project database. Gated; the plaintext goes to .env + a one-time reveal, never into the chat — reference it as its env var.', + parameters: CreateApiKeySchema, + async execute(_id, params: Params, _signal, _onUpdate, ctx) { + const keyName = params.key_name?.trim(); + if (!keyName) return fail('create_api_key requires "key_name".'); + + const resolved = await resolveProjectContext(ctx.cwd, { plane: 'data' }); + if (!resolved.context) return fail(resolved.reason); + const { databaseId, databaseName, apiEndpoint } = resolved.context; + + if (!databaseName) { + return fail( + 'Cannot resolve the app auth endpoint (DATABASE_NAME missing from .env). Re-provision the database, then retry.', + ); + } + + const host = getHost(); + if (!host.deliverSecret) { + return fail( + 'This host cannot deliver secrets (no .env write + reveal flow), so no key was minted. Create the key from a host that supports secret delivery.', + ); + } + + const token = await resolveDataToken(resolved.context); + if (!token.token) { + return fail(token.reason ?? 'Sign in to the app database to create an API key.', { + needsAuth: true, + }); + } + + const authEndpoint = deriveSubdomainEndpoint(apiEndpoint, `auth-${databaseName}`); + if (!authEndpoint) return fail('Cannot derive the app auth endpoint.'); + + const scoped = Boolean(params.entity_ids?.length) || params.read_only === true; + try { + if (scoped && !(await supportsCreateTimeScoping(authEndpoint))) { + return fail( + 'This deployment does not support scoping a principal at create time (no entityIds on createPrincipal). No key was minted. If an unscoped key that acts as the signed-in user is acceptable, ask for one explicitly.', + ); + } + + const dbAuth = auth.createClient({ + endpoint: authEndpoint, + headers: { Authorization: `Bearer ${token.token}` }, + }); + + const principalName = params.principal_name?.trim() || keyName; + const existing = await dbAuth.principal + .findFirst({ + where: { name: { equalTo: principalName } }, + select: { id: true, name: true }, + }) + .unwrap(); + + let principalId = existing.principal?.id; + if (principalId && scoped) { + return fail( + `Principal "${principalName}" already exists, and its scope cannot be verified against the requested one. No key was minted. Use a new principal_name for a scoped key, or mint an unscoped key on the existing principal explicitly.`, + ); + } + if (!principalId) { + // The SDK's PrincipalModel.create sends a nested {principal} input, but + // the live CreatePrincipalInput is flat and its payload only exposes + // `result: UUID` — raw GraphQL is the only working surface. + const created = await rawGraphql( + authEndpoint, + token.token, + 'mutation ($input: CreatePrincipalInput!) { createPrincipal(input: $input) { result } }', + { + input: { + name: principalName, + ...(params.entity_ids?.length && { entityIds: params.entity_ids }), + ...(params.read_only !== undefined && { isReadOnly: params.read_only }), + }, + }, + ); + if (created.errors?.length) { + return fail(`createPrincipal failed: ${created.errors.map((e) => e.message).join('; ')}`); + } + const payload = created.data?.createPrincipal as { result?: string } | undefined; + principalId = payload?.result; + if (!principalId) return fail('createPrincipal returned no principal id.'); + } + + const mint = () => + dbAuth.mutation + .createApiKey( + { + input: { + principalId: principalId!, + keyName, + ...(params.read_only && { accessLevel: 'read_only' }), + expiresIn: { days: params.expires_in_days ?? 90 }, + }, + }, + { select: { result: { select: { apiKey: true, keyId: true, expiresAt: true } } } }, + ) + .execute(); + + let minted = await mint(); + if (!minted.ok && isStepUpError(minted.errors.map((e) => e.message))) { + if (!host.requestStepUp) { + return fail( + 'Creating this key requires MFA step-up, and this host has no step-up flow. Complete step-up in the app (verify your password), then retry.', + ); + } + const verified = await host.requestStepUp(databaseId); + if (!verified) return fail('Step-up verification was not completed. No key was minted.'); + minted = await mint(); + } + if (!minted.ok) { + return fail(`createApiKey failed: ${minted.errors.map((e) => e.message).join('; ')}`); + } + + const record = minted.data?.createApiKey?.result; + if (!record?.apiKey || !record.keyId) return fail('createApiKey returned no key.'); + + const envVar = toEnvVar(keyName); + await host.deliverSecret({ + databaseId, + envVar, + plaintext: record.apiKey, + keyId: record.keyId, + expiresAt: record.expiresAt ?? undefined, + }); + + const scope = describeScope(params.entity_ids, params.read_only === true); + const message = `Created API key "${keyName}" (keyId: ${record.keyId}) for principal "${principalName}" — ${scope}. The key was written to .env as ${envVar} and revealed once to the user; it is not in this conversation. Reference it as ${envVar}.${record.expiresAt ? ` Expires ${record.expiresAt}.` : ''}`; + return { + content: [{ type: 'text', text: message }], + details: { + success: true, + message, + keyId: record.keyId, + envVar, + expiresAt: record.expiresAt ?? undefined, + principalId, + principalName, + scope, + }, + }; + } catch (err) { + return fail(err instanceof Error ? err.message : 'Failed to create the API key.'); + } + }, +}; From 69e13af1fa6e71410771a871a8154f5ee0a3823f Mon Sep 17 00:00:00 2001 From: luca Date: Tue, 4 Aug 2026 00:17:46 +0800 Subject: [PATCH 12/19] feat(harness): confirm-gate create_api_key --- agentic/harness/__tests__/gating.test.ts | 40 ++++++++++++++++++++++ agentic/harness/src/gating/confirm-gate.ts | 10 +++++- agentic/harness/src/gating/prompts.ts | 14 ++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/agentic/harness/__tests__/gating.test.ts b/agentic/harness/__tests__/gating.test.ts index 832339461..e7a05c930 100644 --- a/agentic/harness/__tests__/gating.test.ts +++ b/agentic/harness/__tests__/gating.test.ts @@ -192,6 +192,30 @@ describe('confirm gate: declines are respected', () => { expect(result?.block).toBe(true); expect(confirmCalls()).toBe(1); }); + + it('skips the confirm for tokenless create_api_key but gates it with a token', async () => { + const tokenless = createHarness({ isProjectRunnable: async () => true }); + expect( + await tokenless.gate.onToolCall( + call('create_api_key', 'tc-1', { key_name: 'deploy bot' }), + tokenless.host, + CWD + ) + ).toBeUndefined(); + expect(tokenless.confirmCalls()).toBe(0); + + const withToken = createHarness({ + isProjectRunnable: async () => true, + hasDataToken: async () => true, + }); + const result = await withToken.gate.onToolCall( + call('create_api_key', 'tc-1', { key_name: 'deploy bot' }), + withToken.host, + CWD + ); + expect(result?.block).toBe(true); + expect(withToken.confirmCalls()).toBe(1); + }); }); describe('decline guard canonicalization', () => { @@ -252,4 +276,20 @@ describe('buildConfirmPrompt', () => { expect(remove.title).toBe('Delete entity type?'); expect(remove.message).toMatch(/stay in the API schema/); }); + + it('summarizes scope in the create_api_key prompt', async () => { + const unscoped = buildConfirmPrompt('create_api_key', { key_name: 'deploy bot' }); + expect(unscoped.title).toBe('Create API key?'); + expect(unscoped.message).toContain('"deploy bot"'); + expect(unscoped.message).toContain('unscoped'); + expect(unscoped.message).toMatch(/never to the agent/); + + const scoped = buildConfirmPrompt('create_api_key', { + key_name: 'ci', + entity_ids: ['e-1', 'e-2'], + read_only: true, + }); + expect(scoped.message).toContain('scoped to 2 entities'); + expect(scoped.message).toContain('read-only'); + }); }); diff --git a/agentic/harness/src/gating/confirm-gate.ts b/agentic/harness/src/gating/confirm-gate.ts index c086a5067..07d2a2ed5 100644 --- a/agentic/harness/src/gating/confirm-gate.ts +++ b/agentic/harness/src/gating/confirm-gate.ts @@ -119,7 +119,15 @@ export function createConfirmGate(deps: ConfirmGateDeps): ConfirmGate { } if (!(await deps.isProjectRunnable(cwd))) return; - if (event.toolName === 'add_records' && !(await deps.hasDataToken(cwd))) return; + // Tools that need an app sign-in skip the confirm when no data token + // exists — the tool returns its sign-in prompt instead of making the + // user approve something that fails. + if ( + (event.toolName === 'add_records' || event.toolName === 'create_api_key') && + !(await deps.hasDataToken(cwd)) + ) { + return; + } let resolvedPreview: ConfirmPreview | undefined; if (event.toolName === 'create_template') { diff --git a/agentic/harness/src/gating/prompts.ts b/agentic/harness/src/gating/prompts.ts index df43eabd0..7c446ab99 100644 --- a/agentic/harness/src/gating/prompts.ts +++ b/agentic/harness/src/gating/prompts.ts @@ -13,6 +13,7 @@ export const MUTATING_DB_TOOLS = new Set([ 'delete_template', 'add_records', 'manage_entity_types', + 'create_api_key', 'run_codegen', ]); @@ -201,6 +202,19 @@ export function buildConfirmPrompt( return { title: 'Manage entity types?', message: 'Change entity types in the project database.' }; } } + case 'create_api_key': { + const keyName = str(input, 'key_name') ?? '?'; + const readOnly = input?.read_only === true; + const entityIds = Array.isArray(input?.entity_ids) ? input.entity_ids.length : 0; + const scope = + entityIds > 0 + ? `scoped to ${entityIds} entit${entityIds === 1 ? 'y' : 'ies'}` + : 'unscoped — it acts as your signed-in app user'; + return { + title: 'Create API key?', + message: `Mint API key "${keyName}" (${scope}${readOnly ? ', read-only' : ''}). You may be asked to verify your password; the key is written to .env and shown to you once — never to the agent.`, + }; + } case 'run_codegen': return { title: 'Run codegen?', From 6be011a92b04a061156a8f3b987889c85924d742 Mon Sep 17 00:00:00 2001 From: luca Date: Tue, 4 Aug 2026 00:23:39 +0800 Subject: [PATCH 13/19] feat(pi): step-up request context + cwd in secret delivery --- agentic/pi/__tests__/create-api-key.test.ts | 7 ++++++- agentic/pi/src/host.ts | 14 +++++++++++++- agentic/pi/src/tools/create-api-key.ts | 3 ++- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/agentic/pi/__tests__/create-api-key.test.ts b/agentic/pi/__tests__/create-api-key.test.ts index 7e1af8e96..e9a21de52 100644 --- a/agentic/pi/__tests__/create-api-key.test.ts +++ b/agentic/pi/__tests__/create-api-key.test.ts @@ -200,6 +200,7 @@ describe('create_api_key execute', () => { }); expect(host.deliverSecret).toHaveBeenCalledWith({ databaseId: 'db-1', + cwd: '/tmp/project', envVar: 'DEPLOY_BOT_API_KEY', plaintext: PLAINTEXT, keyId: 'key-1', @@ -223,7 +224,11 @@ describe('create_api_key execute', () => { const result = await run({ key_name: 'deploy bot' }); expect(result.details.success).toBe(true); expect(host.requestStepUp).toHaveBeenCalledTimes(1); - expect(host.requestStepUp).toHaveBeenCalledWith('db-1'); + expect(host.requestStepUp).toHaveBeenCalledWith({ + databaseId: 'db-1', + databaseName: 'demo', + apiEndpoint: 'http://api.localhost:6464/graphql', + }); expect(client.mutation.createApiKey).toHaveBeenCalledTimes(2); expect(JSON.stringify(result)).not.toContain(PLAINTEXT); }); diff --git a/agentic/pi/src/host.ts b/agentic/pi/src/host.ts index ec54f69d6..055ae0e0d 100644 --- a/agentic/pi/src/host.ts +++ b/agentic/pi/src/host.ts @@ -62,12 +62,24 @@ export interface HostProvisionOverlay { */ export type SecretDelivery = { databaseId: string; + /** Project directory whose `.env` receives the key. */ + cwd: string; envVar: string; plaintext: string; keyId: string; expiresAt?: string; }; +/** + * Context for a host-side step-up: enough to derive the per-database auth + * endpoint and look up the app session without re-resolving the project. + */ +export type StepUpRequest = { + databaseId: string; + databaseName: string; + apiEndpoint: string; +}; + export interface PiToolsHost { /** Signed-in platform account, or null/undefined when signed out. */ account(): HostAccount | null | undefined; @@ -101,7 +113,7 @@ export interface PiToolsHost { * process (password dialog + verifyPassword). The password never passes * through pi or the model. Resolve true when step-up succeeded. */ - requestStepUp?(databaseId: string): Promise; + requestStepUp?(request: StepUpRequest): Promise; /** * Deliver a minted secret to the user (.env write + one-time reveal). * Required for create_api_key — without it the tool refuses to mint. diff --git a/agentic/pi/src/tools/create-api-key.ts b/agentic/pi/src/tools/create-api-key.ts index d7d7a11fe..be4f35a8b 100644 --- a/agentic/pi/src/tools/create-api-key.ts +++ b/agentic/pi/src/tools/create-api-key.ts @@ -223,7 +223,7 @@ export const createApiKeyTool: ToolDefinition Date: Tue, 4 Aug 2026 00:44:54 +0800 Subject: [PATCH 14/19] fix(pi): verify existing principal scope before unscoped reuse --- agentic/pi/__tests__/create-api-key.test.ts | 77 ++++++++++++++++++++- agentic/pi/src/tools/create-api-key.ts | 34 +++++++-- 2 files changed, 103 insertions(+), 8 deletions(-) diff --git a/agentic/pi/__tests__/create-api-key.test.ts b/agentic/pi/__tests__/create-api-key.test.ts index e9a21de52..2275c401c 100644 --- a/agentic/pi/__tests__/create-api-key.test.ts +++ b/agentic/pi/__tests__/create-api-key.test.ts @@ -36,7 +36,11 @@ const STEP_UP = { errors: [{ message: 'STEP_UP_REQUIRED: verify your password' }], }; -function makeClient(mintResults: unknown[], existingPrincipal: unknown = null) { +function makeClient( + mintResults: unknown[], + existingPrincipal: unknown = null, + existingScopeRow: unknown = null, +) { const createApiKey = jest.fn(); for (const result of mintResults) { createApiKey.mockReturnValueOnce({ execute: async (): Promise => result }); @@ -47,6 +51,11 @@ function makeClient(mintResults: unknown[], existingPrincipal: unknown = null) { unwrap: async (): Promise => ({ principal: existingPrincipal }), }), }, + principalEntity: { + findFirst: jest.fn().mockReturnValue({ + unwrap: async (): Promise => ({ principalEntity: existingScopeRow }), + }), + }, mutation: { createApiKey }, }; } @@ -179,6 +188,72 @@ describe('create_api_key execute', () => { expect(client.mutation.createApiKey).not.toHaveBeenCalled(); }); + it('fails explicit for a read-only request when isReadOnly is absent', async () => { + useContext(); + mockGetHost.mockReturnValue(makeHost() as never); + mockToken.mockResolvedValue({ token: 'tok' }); + global.fetch = jest.fn(async () => ({ + json: async () => ({ + data: { __type: { inputFields: [{ name: 'name' }, { name: 'entityIds' }] } }, + }), + })) as never; + const client = makeClient([MINTED]); + mockCreateClient.mockReturnValue(client); + + const result = await run({ key_name: 'ro key', read_only: true }); + expect(result.details.success).toBe(false); + expect(result.details.message).toMatch(/isReadOnly/); + expect(client.mutation.createApiKey).not.toHaveBeenCalled(); + }); + + it('reuses an existing principal only after verifying it is unscoped', async () => { + useContext(); + mockGetHost.mockReturnValue(makeHost() as never); + mockToken.mockResolvedValue({ token: 'tok' }); + global.fetch = mockFetchWith(true) as never; + const client = makeClient([MINTED], { id: 'prin-old', name: 'bot', isReadOnly: false }); + mockCreateClient.mockReturnValue(client); + + const result = await run({ key_name: 'k', principal_name: 'bot' }); + expect(result.details.success).toBe(true); + expect(result.details.principalId).toBe('prin-old'); + expect(client.principalEntity.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ where: { principalId: { equalTo: 'prin-old' } } }), + ); + }); + + it('refuses to reuse an entity-scoped principal for an unscoped key', async () => { + useContext(); + mockGetHost.mockReturnValue(makeHost() as never); + mockToken.mockResolvedValue({ token: 'tok' }); + global.fetch = mockFetchWith(true) as never; + const client = makeClient( + [MINTED], + { id: 'prin-old', name: 'bot', isReadOnly: false }, + { id: 'pe-1' }, + ); + mockCreateClient.mockReturnValue(client); + + const result = await run({ key_name: 'k', principal_name: 'bot' }); + expect(result.details.success).toBe(false); + expect(result.details.message).toMatch(/narrower scope/); + expect(client.mutation.createApiKey).not.toHaveBeenCalled(); + }); + + it('refuses to reuse a read-only principal for an unscoped key', async () => { + useContext(); + mockGetHost.mockReturnValue(makeHost() as never); + mockToken.mockResolvedValue({ token: 'tok' }); + global.fetch = mockFetchWith(true) as never; + const client = makeClient([MINTED], { id: 'prin-old', name: 'bot', isReadOnly: true }); + mockCreateClient.mockReturnValue(client); + + const result = await run({ key_name: 'k', principal_name: 'bot' }); + expect(result.details.success).toBe(false); + expect(result.details.message).toMatch(/narrower scope/); + expect(client.mutation.createApiKey).not.toHaveBeenCalled(); + }); + it('mints, delivers the secret out of band, and keeps it out of the result', async () => { useContext(); const host = makeHost(); diff --git a/agentic/pi/src/tools/create-api-key.ts b/agentic/pi/src/tools/create-api-key.ts index be4f35a8b..324a95d48 100644 --- a/agentic/pi/src/tools/create-api-key.ts +++ b/agentic/pi/src/tools/create-api-key.ts @@ -99,14 +99,14 @@ async function rawGraphql( return (await res.json()) as { data?: Record; errors?: { message: string }[] }; } -async function supportsCreateTimeScoping(endpoint: string): Promise { +async function createPrincipalInputFields(endpoint: string): Promise> { const probe = await rawGraphql( endpoint, undefined, '{ __type(name: "CreatePrincipalInput") { inputFields { name } } }', ); const type = probe.data?.__type as { inputFields?: { name: string }[] } | null | undefined; - return Boolean(type?.inputFields?.some((f) => f.name === 'entityIds')); + return new Set((type?.inputFields ?? []).map((f) => f.name)); } type Params = z.infer; @@ -152,10 +152,17 @@ export const createApiKeyTool: ToolDefinition Date: Tue, 4 Aug 2026 01:07:44 +0800 Subject: [PATCH 15/19] fix(pi): mint reused-principal keys with the identity userId --- agentic/pi/__tests__/create-api-key.test.ts | 18 ++++++++++++------ agentic/pi/src/tools/create-api-key.ts | 12 +++++++----- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/agentic/pi/__tests__/create-api-key.test.ts b/agentic/pi/__tests__/create-api-key.test.ts index 2275c401c..dec8adcfe 100644 --- a/agentic/pi/__tests__/create-api-key.test.ts +++ b/agentic/pi/__tests__/create-api-key.test.ts @@ -179,7 +179,7 @@ describe('create_api_key execute', () => { mockGetHost.mockReturnValue(makeHost() as never); mockToken.mockResolvedValue({ token: 'tok' }); global.fetch = mockFetchWith(true) as never; - const client = makeClient([MINTED], { id: 'prin-old', name: 'bot' }); + const client = makeClient([MINTED], { id: 'prin-row', name: 'bot', userId: 'prin-user' }); mockCreateClient.mockReturnValue(client); const result = await run({ key_name: 'k', principal_name: 'bot', entity_ids: ['e-1'] }); @@ -211,15 +211,21 @@ describe('create_api_key execute', () => { mockGetHost.mockReturnValue(makeHost() as never); mockToken.mockResolvedValue({ token: 'tok' }); global.fetch = mockFetchWith(true) as never; - const client = makeClient([MINTED], { id: 'prin-old', name: 'bot', isReadOnly: false }); + const client = makeClient([MINTED], { + id: 'prin-row', + name: 'bot', + userId: 'prin-user', + isReadOnly: false, + }); mockCreateClient.mockReturnValue(client); const result = await run({ key_name: 'k', principal_name: 'bot' }); expect(result.details.success).toBe(true); - expect(result.details.principalId).toBe('prin-old'); + expect(result.details.principalId).toBe('prin-user'); expect(client.principalEntity.findFirst).toHaveBeenCalledWith( - expect.objectContaining({ where: { principalId: { equalTo: 'prin-old' } } }), + expect.objectContaining({ where: { principalId: { equalTo: 'prin-row' } } }), ); + expect(client.mutation.createApiKey.mock.calls[0][0].input.principalId).toBe('prin-user'); }); it('refuses to reuse an entity-scoped principal for an unscoped key', async () => { @@ -229,7 +235,7 @@ describe('create_api_key execute', () => { global.fetch = mockFetchWith(true) as never; const client = makeClient( [MINTED], - { id: 'prin-old', name: 'bot', isReadOnly: false }, + { id: 'prin-row', name: 'bot', userId: 'prin-user', isReadOnly: false }, { id: 'pe-1' }, ); mockCreateClient.mockReturnValue(client); @@ -245,7 +251,7 @@ describe('create_api_key execute', () => { mockGetHost.mockReturnValue(makeHost() as never); mockToken.mockResolvedValue({ token: 'tok' }); global.fetch = mockFetchWith(true) as never; - const client = makeClient([MINTED], { id: 'prin-old', name: 'bot', isReadOnly: true }); + const client = makeClient([MINTED], { id: 'prin-row', name: 'bot', userId: 'prin-user', isReadOnly: true }); mockCreateClient.mockReturnValue(client); const result = await run({ key_name: 'k', principal_name: 'bot' }); diff --git a/agentic/pi/src/tools/create-api-key.ts b/agentic/pi/src/tools/create-api-key.ts index 324a95d48..b7481d400 100644 --- a/agentic/pi/src/tools/create-api-key.ts +++ b/agentic/pi/src/tools/create-api-key.ts @@ -174,20 +174,22 @@ export const createApiKeyTool: ToolDefinition Date: Tue, 4 Aug 2026 03:54:36 +0800 Subject: [PATCH 16/19] fix(cli): split coalesced stdin chunks so pasted passwords survive login --- agentic/cli/__tests__/keypress-chunks.test.ts | 71 +++++++++++++++++++ agentic/cli/src/commands.ts | 2 + agentic/cli/src/keypress-chunks.ts | 54 ++++++++++++++ 3 files changed, 127 insertions(+) create mode 100644 agentic/cli/__tests__/keypress-chunks.test.ts create mode 100644 agentic/cli/src/keypress-chunks.ts diff --git a/agentic/cli/__tests__/keypress-chunks.test.ts b/agentic/cli/__tests__/keypress-chunks.test.ts new file mode 100644 index 000000000..797df3cf4 --- /dev/null +++ b/agentic/cli/__tests__/keypress-chunks.test.ts @@ -0,0 +1,71 @@ +import { EventEmitter } from 'events'; + +import { segmentKeys, splitCoalescedKeypresses } from '../src/keypress-chunks'; + +describe('segmentKeys', () => { + it('keeps single characters whole', () => { + expect(segmentKeys('a')).toEqual(['a']); + expect(segmentKeys('\r')).toEqual(['\r']); + }); + + it('splits a pasted string into single keys', () => { + expect(segmentKeys('QaAgentLogin!2026\r')).toEqual([...'QaAgentLogin!2026', '\r']); + }); + + it('keeps CSI escape sequences intact', () => { + expect(segmentKeys('\u001b[B')).toEqual(['\u001b[B']); + expect(segmentKeys('\u001b[B\u001b[B\r')).toEqual(['\u001b[B', '\u001b[B', '\r']); + expect(segmentKeys('\u001b[1;5D')).toEqual(['\u001b[1;5D']); + expect(segmentKeys('\u001b[3~')).toEqual(['\u001b[3~']); + }); + + it('keeps two-char alt sequences intact', () => { + expect(segmentKeys('\u001bb')).toEqual(['\u001bb']); + expect(segmentKeys('\u001b')).toEqual(['\u001b']); + }); + + it('splits mixed text and escapes', () => { + expect(segmentKeys('ab\u001b[Ac')).toEqual(['a', 'b', '\u001b[A', 'c']); + }); +}); + +describe('splitCoalescedKeypresses', () => { + function fakePrompter() { + const input = new EventEmitter(); + const received: string[] = []; + const kp = { + input, + listeners: {} as Record, + dataHandler: (key: string) => received.push(key) + }; + input.on('data', kp.dataHandler); + return { prompter: { keypress: kp } as any, kp, input, received }; + } + + it('feeds coalesced chunks to the handler one key at a time', () => { + const { prompter, input, received } = fakePrompter(); + splitCoalescedKeypresses(prompter); + input.emit('data', 'n!2026\r'); + expect(received).toEqual(['n', '!', '2', '0', '2', '6', '\r']); + }); + + it('delivers a chunk whole when it is itself a registered key', () => { + const { prompter, kp, input, received } = fakePrompter(); + kp.listeners['\u001b[B'] = [(): void => undefined]; + splitCoalescedKeypresses(prompter); + input.emit('data', '\u001b[B'); + expect(received).toEqual(['\u001b[B']); + }); + + it('replaces dataHandler so keypress.destroy removes the wrapper', () => { + const { prompter, kp, input } = fakePrompter(); + const original = kp.dataHandler; + splitCoalescedKeypresses(prompter); + expect(kp.dataHandler).not.toBe(original); + expect(input.listeners('data')).toEqual([kp.dataHandler]); + }); + + it('is a no-op without a keypress handler (noTty)', () => { + expect(() => splitCoalescedKeypresses({ keypress: null } as any)).not.toThrow(); + }); +}); diff --git a/agentic/cli/src/commands.ts b/agentic/cli/src/commands.ts index 3192ac035..fb153d9b2 100644 --- a/agentic/cli/src/commands.ts +++ b/agentic/cli/src/commands.ts @@ -5,6 +5,7 @@ import { loadSession } from './account-store'; import { signIn, signOut } from './auth'; import { BACKEND_PRESETS, BackendConfig, loadBackendConfig, saveBackendConfig } from './backend-store'; import { AgentCliConfig, defaultManifest, saveManifestFile } from './config'; +import { splitCoalescedKeypresses } from './keypress-chunks'; import { assembleSkills } from './skills'; const log = (msg: string) => console.log(`[agent] ${msg}`); @@ -73,6 +74,7 @@ export async function login(config: AgentCliConfig, argv: Record { + if (kp.listeners?.[chunk]?.length) { + original(chunk); + return; + } + for (const key of segmentKeys(chunk)) original(key); + }; + kp.dataHandler = wrapped; + kp.input.on('data', wrapped); +} + +interface KeypressInternals { + dataHandler: ((key: string) => void) | null; + listeners: Record; + input: NodeJS.ReadStream; +} From 385fcc8d9b673c0f16936822e9473a2a8738fc30 Mon Sep 17 00:00:00 2001 From: luca Date: Tue, 4 Aug 2026 03:54:36 +0800 Subject: [PATCH 17/19] test(cli): expect 18 db tools after pi tool additions --- agentic/cli/__tests__/db-tools.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agentic/cli/__tests__/db-tools.test.ts b/agentic/cli/__tests__/db-tools.test.ts index 10712abdd..1cc16e658 100644 --- a/agentic/cli/__tests__/db-tools.test.ts +++ b/agentic/cli/__tests__/db-tools.test.ts @@ -49,7 +49,7 @@ describe('materializeDbTools', () => { on: () => {} }); expect(registered).toContain('provision_database'); - expect(registered).toHaveLength(16); + expect(registered).toHaveLength(18); }); function loadHost() { From 412ba27081799f44ce4ec59861e3a956727bf9bb Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Tue, 4 Aug 2026 01:05:44 +0000 Subject: [PATCH 18/19] feat(codegen): cli.stashName so generated CLIs can share one signed-in state Also bumps genomic to ^5.6.4 so the workspace resolves a single inquirerer (4.9.3). --- .../__tests__/codegen/cli-generator.test.ts | 43 +++++++++++++++++++ .../core/codegen/cli/executor-generator.ts | 27 +++++++++--- .../src/core/codegen/cli/helpers-generator.ts | 15 +++++-- graphql/codegen/src/core/codegen/cli/index.ts | 13 ++++-- graphql/codegen/src/core/generate.ts | 1 + graphql/codegen/src/types/config.ts | 9 ++++ pgpm/cli/package.json | 2 +- pgpm/core/package.json | 2 +- pnpm-lock.yaml | 28 ++++-------- 9 files changed, 104 insertions(+), 36 deletions(-) diff --git a/graphql/codegen/src/__tests__/codegen/cli-generator.test.ts b/graphql/codegen/src/__tests__/codegen/cli-generator.test.ts index 16e6e8262..5fd6e84ff 100644 --- a/graphql/codegen/src/__tests__/codegen/cli-generator.test.ts +++ b/graphql/codegen/src/__tests__/codegen/cli-generator.test.ts @@ -719,3 +719,46 @@ describe('multi-target cli with custom builtinNames', () => { expect(fileNames).not.toContain('commands/context.ts'); }); }); + +describe('cli stashName', () => { + it('omits stashName from the store call when not configured', () => { + const single = generateCli({ + tables: [carTable], + customOperations: { queries: [], mutations: [] }, + config: { cli: { toolName: 'myapp' } }, + }); + const executor = single.files.find((f) => f.fileName === 'executor.ts'); + expect(executor!.content).toContain('createConfigStore("myapp")'); + }); + + it('passes stashName to the store while keeping toolName', () => { + const single = generateCli({ + tables: [carTable], + customOperations: { queries: [], mutations: [] }, + config: { cli: { toolName: 'myapp', stashName: 'shared' } }, + }); + const executor = single.files.find((f) => f.fileName === 'executor.ts'); + expect(executor!.content).toContain('createConfigStore("myapp", {'); + expect(executor!.content).toContain('stashName: "shared"'); + }); + + it('passes stashName to multi-target executor and helpers', () => { + const multi = generateMultiTargetCli({ + toolName: 'myapp', + stashName: 'shared', + targets: [ + { + name: 'app', + endpoint: 'http://app.localhost/graphql', + ormImportPath: '../../generated/app/orm', + tables: [carTable], + customOperations: { queries: [], mutations: [] }, + }, + ], + }); + for (const fileName of ['executor.ts', 'helpers.ts']) { + const file = multi.files.find((f) => f.fileName === fileName); + expect(file!.content).toContain('stashName: "shared"'); + } + }); +}); diff --git a/graphql/codegen/src/core/codegen/cli/executor-generator.ts b/graphql/codegen/src/core/codegen/cli/executor-generator.ts index a4cc2e0ed..15b18bcef 100644 --- a/graphql/codegen/src/core/codegen/cli/executor-generator.ts +++ b/graphql/codegen/src/core/codegen/cli/executor-generator.ts @@ -30,7 +30,23 @@ function createImportDeclaration( return decl; } -export function generateExecutorFile(toolName: string): GeneratedFile { +/** + * `createConfigStore(toolName)`, or `createConfigStore(toolName, { stashName })` + * when the CLI shares its signed-in state with other tools of the same product. + */ +function createStoreCall(toolName: string, stashName?: string): t.CallExpression { + const args: t.Expression[] = [t.stringLiteral(toolName)]; + if (stashName) { + args.push( + t.objectExpression([ + t.objectProperty(t.identifier('stashName'), t.stringLiteral(stashName)), + ]), + ); + } + return t.callExpression(t.identifier('createConfigStore'), args); +} + +export function generateExecutorFile(toolName: string, stashName?: string): GeneratedFile { const statements: t.Statement[] = []; statements.push( @@ -44,9 +60,7 @@ export function generateExecutorFile(toolName: string): GeneratedFile { t.variableDeclaration('const', [ t.variableDeclarator( t.identifier('store'), - t.callExpression(t.identifier('createConfigStore'), [ - t.stringLiteral(toolName), - ]), + createStoreCall(toolName, stashName), ), ]), ); @@ -238,6 +252,7 @@ export function generateExecutorFile(toolName: string): GeneratedFile { export function generateMultiTargetExecutorFile( toolName: string, targets: MultiTargetExecutorInput[], + stashName?: string, ): GeneratedFile { const statements: t.Statement[] = []; @@ -260,9 +275,7 @@ export function generateMultiTargetExecutorFile( t.variableDeclaration('const', [ t.variableDeclarator( t.identifier('store'), - t.callExpression(t.identifier('createConfigStore'), [ - t.stringLiteral(toolName), - ]), + createStoreCall(toolName, stashName), ), ]), ); diff --git a/graphql/codegen/src/core/codegen/cli/helpers-generator.ts b/graphql/codegen/src/core/codegen/cli/helpers-generator.ts index 1c04bab38..9d2b40c51 100644 --- a/graphql/codegen/src/core/codegen/cli/helpers-generator.ts +++ b/graphql/codegen/src/core/codegen/cli/helpers-generator.ts @@ -37,6 +37,7 @@ export interface HelpersGeneratorInput { export function generateHelpersFile( toolName: string, targets: HelpersGeneratorInput[], + stashName?: string, ): GeneratedFile { const statements: t.Statement[] = []; @@ -61,14 +62,20 @@ export function generateHelpersFile( ); } - // const store = createConfigStore('toolName'); + // const store = createConfigStore('toolName', { stashName: 'product' }); + const storeArgs: t.Expression[] = [t.stringLiteral(toolName)]; + if (stashName) { + storeArgs.push( + t.objectExpression([ + t.objectProperty(t.identifier('stashName'), t.stringLiteral(stashName)), + ]), + ); + } statements.push( t.variableDeclaration('const', [ t.variableDeclarator( t.identifier('store'), - t.callExpression(t.identifier('createConfigStore'), [ - t.stringLiteral(toolName), - ]), + t.callExpression(t.identifier('createConfigStore'), storeArgs), ), ]), ); diff --git a/graphql/codegen/src/core/codegen/cli/index.ts b/graphql/codegen/src/core/codegen/cli/index.ts index 9bf444c25..d6e84f5e8 100644 --- a/graphql/codegen/src/core/codegen/cli/index.ts +++ b/graphql/codegen/src/core/codegen/cli/index.ts @@ -48,7 +48,10 @@ export function generateCli(options: GenerateCliOptions): GenerateCliResult { ? cliConfig.toolName : 'app'; - const executorFile = generateExecutorFile(toolName); + const stashName = + typeof cliConfig === 'object' ? cliConfig.stashName : undefined; + + const executorFile = generateExecutorFile(toolName, stashName); files.push(executorFile); const utilsFile = generateUtilsFile(); @@ -127,6 +130,8 @@ export interface MultiTargetCliTarget { export interface GenerateMultiTargetCliOptions { toolName: string; + /** Directory identity to share signed-in state with sibling tools. */ + stashName?: string; builtinNames?: BuiltinNames; targets: MultiTargetCliTarget[]; /** Generate a runnable index.ts entry point */ @@ -157,7 +162,7 @@ export function resolveBuiltinNames( export function generateMultiTargetCli( options: GenerateMultiTargetCliOptions, ): GenerateCliResult { - const { toolName, targets } = options; + const { toolName, stashName, targets } = options; const files: GeneratedFile[] = []; const targetNames = targets.map((t) => t.name); @@ -168,7 +173,7 @@ export function generateMultiTargetCli( endpoint: t.endpoint, ormImportPath: t.ormImportPath, })); - const executorFile = generateMultiTargetExecutorFile(toolName, executorInputs); + const executorFile = generateMultiTargetExecutorFile(toolName, executorInputs, stashName); files.push(executorFile); const utilsFile = generateUtilsFile(); @@ -201,7 +206,7 @@ export function generateMultiTargetCli( name: t.name, ormImportPath: t.ormImportPath, })); - const helpersFile = generateHelpersFile(toolName, helpersInputs); + const helpersFile = generateHelpersFile(toolName, helpersInputs, stashName); files.push(helpersFile); let totalTables = 0; diff --git a/graphql/codegen/src/core/generate.ts b/graphql/codegen/src/core/generate.ts index 2a589dcd8..a3ed3a11f 100644 --- a/graphql/codegen/src/core/generate.ts +++ b/graphql/codegen/src/core/generate.ts @@ -747,6 +747,7 @@ export async function generateMulti( const firstTargetConfig = configs[names[0]]; const { files } = generateMultiTargetCli({ toolName, + stashName: cliConfig.stashName, builtinNames: cliConfig.builtinNames, targets: cliTargets, entryPoint: cliConfig.entryPoint, diff --git a/graphql/codegen/src/types/config.ts b/graphql/codegen/src/types/config.ts index 14261fb1b..e2aaf3b21 100644 --- a/graphql/codegen/src/types/config.ts +++ b/graphql/codegen/src/types/config.ts @@ -212,6 +212,15 @@ export interface CliConfig { */ toolName?: string; + /** + * Directory identity for the stored contexts and credentials, when several + * tools are one product and should share a single signed-in state (e.g. a + * generated CLI, an agent CLI and a desktop app all using `constructive`). + * `toolName` still drives env-var prefixes and help text. + * @default toolName + */ + stashName?: string; + /** * Override infra command names (for collision handling) * Defaults: auth -> 'auth' (renamed to 'credentials' on collision), diff --git a/pgpm/cli/package.json b/pgpm/cli/package.json index 747647dfd..b4fe521e3 100644 --- a/pgpm/cli/package.json +++ b/pgpm/cli/package.json @@ -58,7 +58,7 @@ "@pgsql/quotes": "^18.2.1", "appstash": "^0.7.0", "find-and-require-package-json": "^0.9.1", - "genomic": "^5.6.2", + "genomic": "^5.6.4", "inquirerer": "^4.9.3", "js-yaml": "^4.1.0", "pg-cache": "workspace:^", diff --git a/pgpm/core/package.json b/pgpm/core/package.json index 654369311..f177ea335 100644 --- a/pgpm/core/package.json +++ b/pgpm/core/package.json @@ -58,7 +58,7 @@ "@pgpmjs/transform": "workspace:^", "@pgpmjs/types": "workspace:^", "csv-to-pg": "workspace:^", - "genomic": "^5.6.2", + "genomic": "^5.6.4", "git-changed": "^0.3.0", "glob": "^13.0.6", "parse-package-name": "^1.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4739f08a5..46d00c274 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2859,8 +2859,8 @@ importers: specifier: ^0.9.1 version: 0.9.1 genomic: - specifier: ^5.6.2 - version: 5.6.2 + specifier: ^5.6.4 + version: 5.6.4 inquirerer: specifier: ^4.9.3 version: 4.9.3 @@ -2951,8 +2951,8 @@ importers: specifier: workspace:^ version: link:../../packages/csv-to-pg/dist genomic: - specifier: ^5.6.2 - version: 5.6.2 + specifier: ^5.6.4 + version: 5.6.4 git-changed: specifier: ^0.3.0 version: 0.3.0 @@ -7943,8 +7943,8 @@ packages: resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} engines: {node: '>=18'} - genomic@5.6.2: - resolution: {integrity: sha512-y2LK1KQjeZZ4WT0DEQhjTxMNs+hsoZTclIqdnU5Xo3Ie8phDB6ynw8Sk2NLtELL7H3Q26tvZBJkckIkaSa0Lag==} + genomic@5.6.4: + resolution: {integrity: sha512-k4wUPBCMn5k7UNuYfYQ0e05yYp99hgo6gG7YToS/62Kr+2Ax4bKnXs2ZWfAB4HyyjTQ36d9Kaj4gfJquuUDcbQ==} gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} @@ -8396,9 +8396,6 @@ packages: resolution: {integrity: sha512-UjOaSel/iddGZJ5xP/Eixh6dY1XghiBw4XK13rCCIJcJfyhhoul/7KhLLUGtebEj6GDYM6Vnx/mVsjx2L/mFIA==} engines: {node: '>=12.0.0'} - inquirerer@4.9.1: - resolution: {integrity: sha512-RXgbivwNs9luseSHnjIcJ05A8VRGcdCQuLjyWWBmI1zwyorkSzCw4KyK8634a5KrbbXehSJHBE3rIrtgJJiBEQ==} - inquirerer@4.9.3: resolution: {integrity: sha512-f3iJubKDBE5Cp9NnJ0cRGt9zngbZ78b62QvrbRjwS13VX+s+fcJvcMaEv/3Es46dIyjDtFfhvGZa9/krNJnSbw==} @@ -15605,10 +15602,10 @@ snapshots: transitivePeerDependencies: - supports-color - genomic@5.6.2: + genomic@5.6.4: dependencies: - appstash: 0.7.0 - inquirerer: 4.9.1 + appstash: 0.7.1 + inquirerer: 4.9.3 gensync@1.0.0-beta.2: {} @@ -16293,13 +16290,6 @@ snapshots: transitivePeerDependencies: - '@types/node' - inquirerer@4.9.1: - dependencies: - deepmerge: 4.3.1 - find-and-require-package-json: 0.9.1 - minimist: 1.2.8 - yanse: 0.2.1 - inquirerer@4.9.3: dependencies: deepmerge: 4.3.1 From 20cb70ded9bc03f69613e8b5bcd0cb2174c03373 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Tue, 4 Aug 2026 01:19:14 +0000 Subject: [PATCH 19/19] feat(cli): one shared Constructive login store csdk, the agent CLI and the desktop app now read the same ~/.constructive contexts and credentials; deletes the csdk store duplicate and the agent's account.json/backend-config.json (migrated on first run), and drops the inquirerer workarounds now fixed in 4.9.3. --- agentic/cli/__tests__/auth.test.ts | 55 +++---- agentic/cli/__tests__/commands.test.ts | 17 +- agentic/cli/__tests__/db-tools.test.ts | 6 +- agentic/cli/__tests__/keypress-chunks.test.ts | 71 --------- agentic/cli/__tests__/stores.test.ts | 120 +++++++++----- agentic/cli/package.json | 3 +- agentic/cli/src/account-store.ts | 113 +++++-------- agentic/cli/src/auth.ts | 81 ++++++---- agentic/cli/src/backend-store.ts | 72 ++++++--- agentic/cli/src/commands.ts | 46 +++--- agentic/cli/src/config.ts | 78 +++++++-- agentic/cli/src/credentials.ts | 4 +- agentic/cli/src/index.ts | 4 +- agentic/cli/src/keypress-chunks.ts | 54 ------- agentic/harness/package.json | 2 +- graphql/server-test/package.json | 2 +- packages/cli/package.json | 2 +- pgpm/cli/package.json | 4 +- pgpm/core/package.json | 2 +- pnpm-lock.yaml | 44 +++--- sdk/constructive-cli/package.json | 2 +- sdk/constructive-cli/scripts/generate-sdk.ts | 1 + .../src/admin/cli/executor.ts | 4 +- .../src/agent/cli/executor.ts | 4 +- sdk/constructive-cli/src/api/cli/executor.ts | 4 +- sdk/constructive-cli/src/auth/cli/executor.ts | 4 +- sdk/constructive-cli/src/cli-commands.ts | 19 ++- .../src/compute/cli/executor.ts | 4 +- sdk/constructive-cli/src/config-store.ts | 149 ------------------ .../src/config/cli/executor.ts | 4 +- .../src/infra/cli/executor.ts | 4 +- .../src/modules/cli/executor.ts | 4 +- .../src/objects/cli/executor.ts | 4 +- .../src/usage/cli/executor.ts | 4 +- 34 files changed, 439 insertions(+), 552 deletions(-) delete mode 100644 agentic/cli/__tests__/keypress-chunks.test.ts delete mode 100644 agentic/cli/src/keypress-chunks.ts delete mode 100644 sdk/constructive-cli/src/config-store.ts diff --git a/agentic/cli/__tests__/auth.test.ts b/agentic/cli/__tests__/auth.test.ts index aa123ca34..8fb7e111a 100644 --- a/agentic/cli/__tests__/auth.test.ts +++ b/agentic/cli/__tests__/auth.test.ts @@ -1,3 +1,4 @@ +import { ConfigStore } from 'appstash'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -18,11 +19,11 @@ const unwrappable = (value: unknown) => ({ unwrap: () => Promise.resolve(value) const failing = (err: unknown) => ({ unwrap: () => Promise.reject(err) }); let home: string; -let accountFile: string; +let store: ConfigStore; beforeEach(() => { home = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-cli-auth-')); - accountFile = loadConfig(home).accountFile; + store = loadConfig(home).store; createClient.mockReset(); }); @@ -60,7 +61,7 @@ describe('signIn', () => { }); const session = await signIn({ - accountFile, + store, authEndpoint: AUTH_ENDPOINT, email: ' dev@example.com ', password: 'pw' @@ -73,7 +74,7 @@ describe('signIn', () => { expect(session.userId).toBe('user-1'); expect(session.apiKey).toBe('cnc_live_sk_new'); expect(session.keyId).toBe('key-new'); - expect(loadSession(accountFile)).toEqual(session); + expect(loadSession(store)).toEqual(session); expect(createClient).toHaveBeenCalledWith({ endpoint: AUTH_ENDPOINT }); expect(createClient).toHaveBeenCalledWith({ endpoint: AUTH_ENDPOINT, @@ -89,23 +90,23 @@ describe('signIn', () => { revokeApiKey: jest.fn() }); - const session = await signIn({ accountFile, authEndpoint: AUTH_ENDPOINT, email: 'dev@example.com', password: 'pw' }); + const session = await signIn({ store, authEndpoint: AUTH_ENDPOINT, email: 'dev@example.com', password: 'pw' }); expect(session.apiKey).toBeUndefined(); - expect(loadSession(accountFile)?.accessToken).toBe('access-token'); + expect(loadSession(store)?.accessToken).toBe('access-token'); warn.mockRestore(); }); it('rejects when no access token comes back (MFA)', async () => { mockClient({ signIn: jest.fn(() => unwrappable({ signIn: { result: {} } })) }); await expect( - signIn({ accountFile, authEndpoint: AUTH_ENDPOINT, email: 'dev@example.com', password: 'pw' }) + signIn({ store, authEndpoint: AUTH_ENDPOINT, email: 'dev@example.com', password: 'pw' }) ).rejects.toThrow('Authentication returned no access token (MFA may be required).'); - expect(loadSession(accountFile)).toBeNull(); + expect(loadSession(store)).toBeNull(); }); it('rejects empty credentials without a network call', async () => { await expect( - signIn({ accountFile, authEndpoint: AUTH_ENDPOINT, email: ' ', password: 'pw' }) + signIn({ store, authEndpoint: AUTH_ENDPOINT, email: ' ', password: 'pw' }) ).rejects.toThrow('Email and password are required.'); expect(createClient).not.toHaveBeenCalled(); }); @@ -116,7 +117,7 @@ describe('signIn', () => { }); mockClient({ signIn: jest.fn(() => failing(gqlError)) }); await expect( - signIn({ accountFile, authEndpoint: AUTH_ENDPOINT, email: 'dev@example.com', password: 'nope' }) + signIn({ store, authEndpoint: AUTH_ENDPOINT, email: 'dev@example.com', password: 'nope' }) ).rejects.toThrow('Invalid credentials'); }); }); @@ -130,22 +131,22 @@ describe('refreshApiKeyIfNeeded', () => { }; it('returns signed-out without a session', async () => { - await expect(refreshApiKeyIfNeeded({ accountFile, authEndpoint: AUTH_ENDPOINT })).resolves.toBe('signed-out'); + await expect(refreshApiKeyIfNeeded({ store, authEndpoint: AUTH_ENDPOINT })).resolves.toBe('signed-out'); }); it('returns ok for a fresh key without a network call', async () => { - saveSession(accountFile, { + saveSession(store, { ...baseSession, apiKey: 'k', keyId: 'id', apiKeyExpiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365).toISOString() }); - await expect(refreshApiKeyIfNeeded({ accountFile, authEndpoint: AUTH_ENDPOINT })).resolves.toBe('ok'); + await expect(refreshApiKeyIfNeeded({ store, authEndpoint: AUTH_ENDPOINT })).resolves.toBe('ok'); expect(createClient).not.toHaveBeenCalled(); }); it('re-mints an expiring key and persists it', async () => { - saveSession(accountFile, { + saveSession(store, { ...baseSession, apiKey: 'old', keyId: 'old-id', @@ -156,35 +157,35 @@ describe('refreshApiKeyIfNeeded', () => { revokeApiKey: jest.fn(() => unwrappable({ revokeApiKey: { result: true } })) }); - await expect(refreshApiKeyIfNeeded({ accountFile, authEndpoint: AUTH_ENDPOINT })).resolves.toBe('reminted'); + await expect(refreshApiKeyIfNeeded({ store, authEndpoint: AUTH_ENDPOINT })).resolves.toBe('reminted'); expect(mutation.revokeApiKey).toHaveBeenCalledWith({ input: { keyId: 'old-id' } }, expect.anything()); - expect(loadSession(accountFile)?.apiKey).toBe('cnc_live_sk_new'); + expect(loadSession(store)?.apiKey).toBe('cnc_live_sk_new'); }); it('returns reauth-required on a step-up error', async () => { - saveSession(accountFile, baseSession); + saveSession(store, baseSession); mockClient({ createApiKey: jest.fn(() => failing({ errors: [{ extensions: { code: 'STEP_UP_REQUIRED' } }] })), revokeApiKey: jest.fn() }); - await expect(refreshApiKeyIfNeeded({ accountFile, authEndpoint: AUTH_ENDPOINT })).resolves.toBe('reauth-required'); + await expect(refreshApiKeyIfNeeded({ store, authEndpoint: AUTH_ENDPOINT })).resolves.toBe('reauth-required'); }); it('returns unavailable on other errors', async () => { const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); - saveSession(accountFile, baseSession); + saveSession(store, baseSession); mockClient({ createApiKey: jest.fn(() => failing(new Error('boom'))), revokeApiKey: jest.fn() }); - await expect(refreshApiKeyIfNeeded({ accountFile, authEndpoint: AUTH_ENDPOINT })).resolves.toBe('unavailable'); + await expect(refreshApiKeyIfNeeded({ store, authEndpoint: AUTH_ENDPOINT })).resolves.toBe('unavailable'); warn.mockRestore(); }); }); describe('signOut', () => { it('revokes the key and clears the session', async () => { - saveSession(accountFile, { + saveSession(store, { userId: 'user-1', email: 'dev@example.com', accessToken: 'access-token', @@ -196,14 +197,14 @@ describe('signOut', () => { revokeApiKey: jest.fn(() => unwrappable({ revokeApiKey: { result: true } })) }); - await expect(signOut({ accountFile, authEndpoint: AUTH_ENDPOINT })).resolves.toBe(true); + await expect(signOut({ store, authEndpoint: AUTH_ENDPOINT })).resolves.toBe(true); expect(mutation.revokeApiKey).toHaveBeenCalledWith({ input: { keyId: 'key-1' } }, expect.anything()); - expect(loadSession(accountFile)).toBeNull(); + expect(loadSession(store)).toBeNull(); }); it('clears the session even when the revoke fails', async () => { const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); - saveSession(accountFile, { + saveSession(store, { userId: 'user-1', email: 'dev@example.com', accessToken: 'access-token', @@ -212,13 +213,13 @@ describe('signOut', () => { }); mockClient({ revokeApiKey: jest.fn(() => failing(new Error('offline'))) }); - await expect(signOut({ accountFile, authEndpoint: AUTH_ENDPOINT })).resolves.toBe(true); - expect(loadSession(accountFile)).toBeNull(); + await expect(signOut({ store, authEndpoint: AUTH_ENDPOINT })).resolves.toBe(true); + expect(loadSession(store)).toBeNull(); warn.mockRestore(); }); it('is a no-op when signed out', async () => { - await expect(signOut({ accountFile, authEndpoint: AUTH_ENDPOINT })).resolves.toBe(false); + await expect(signOut({ store, authEndpoint: AUTH_ENDPOINT })).resolves.toBe(false); expect(createClient).not.toHaveBeenCalled(); }); }); diff --git a/agentic/cli/__tests__/commands.test.ts b/agentic/cli/__tests__/commands.test.ts index 7bfbed426..5f178830c 100644 --- a/agentic/cli/__tests__/commands.test.ts +++ b/agentic/cli/__tests__/commands.test.ts @@ -59,12 +59,13 @@ describe('login', () => { await login(config, { backend: 'localnet', email: 'dev@example.com', password: 'pw' }); expect(signInMock).toHaveBeenCalledWith({ - accountFile: config.accountFile, + store: config.store, + context: 'localnet', authEndpoint: BACKEND_PRESETS.localnet.authEndpoint, email: 'dev@example.com', password: 'pw' }); - expect(loadBackendConfig(config.backendFile)).toEqual(BACKEND_PRESETS.localnet); + expect(loadBackendConfig(config.store)).toEqual(BACKEND_PRESETS.localnet); expect(output()).toContain('signed in as dev@example.com'); expect(output()).toContain('cnc_li...abcd'); }); @@ -82,7 +83,7 @@ describe('login', () => { expect(signInMock).toHaveBeenCalledWith( expect.objectContaining({ authEndpoint: 'https://auth.example.com/graphql' }) ); - expect(loadBackendConfig(config.backendFile)).toEqual({ + expect(loadBackendConfig(config.store)).toEqual({ apiEndpoint: 'https://api.example.com/graphql', authEndpoint: 'https://auth.example.com/graphql', modulesEndpoint: 'https://modules.example.com/graphql' @@ -110,19 +111,19 @@ describe('login', () => { await expect( login(config, { backend: 'devnet', email: 'dev@example.com', password: 'bad' }) ).rejects.toThrow('Invalid credentials'); - expect(loadBackendConfig(config.backendFile)).toBeNull(); + expect(loadBackendConfig(config.store)).toBeNull(); }); }); describe('logout', () => { it('signs out against the stored backend', async () => { - saveBackendConfig(config.backendFile, BACKEND_PRESETS.devnet); + saveBackendConfig(config.store, BACKEND_PRESETS.devnet); signOutMock.mockResolvedValue(true); await logout(config); expect(signOutMock).toHaveBeenCalledWith({ - accountFile: config.accountFile, + store: config.store, authEndpoint: BACKEND_PRESETS.devnet.authEndpoint }); expect(output()).toContain('signed out'); @@ -137,8 +138,8 @@ describe('logout', () => { describe('whoami', () => { it('prints the session details with a masked API key', () => { - saveSession(config.accountFile, session); - saveBackendConfig(config.backendFile, BACKEND_PRESETS.localnet); + saveSession(config.store, session); + saveBackendConfig(config.store, BACKEND_PRESETS.localnet); whoami(config); diff --git a/agentic/cli/__tests__/db-tools.test.ts b/agentic/cli/__tests__/db-tools.test.ts index 1cc16e658..4ba9b5f4d 100644 --- a/agentic/cli/__tests__/db-tools.test.ts +++ b/agentic/cli/__tests__/db-tools.test.ts @@ -80,14 +80,14 @@ describe('materializeDbTools', () => { const { config, host } = loadHost(); expect(host.account()).toBeNull(); - saveSession(config.accountFile, { + saveBackendConfig(config.store, BACKEND_PRESETS.devnet); + saveSession(config.store, { userId: 'stored-user', email: 'dev@example.com', accessToken: 'stored-token', apiKey: 'stored-key', signedInAt: 1 }); - saveBackendConfig(config.backendFile, BACKEND_PRESETS.devnet); expect(host.account()).toEqual({ userId: 'stored-user', @@ -102,7 +102,7 @@ describe('materializeDbTools', () => { it('lets env vars beat the stored session', () => { const { config, host } = loadHost(); - saveSession(config.accountFile, { + saveSession(config.store, { userId: 'stored-user', email: 'dev@example.com', accessToken: 'stored-token', diff --git a/agentic/cli/__tests__/keypress-chunks.test.ts b/agentic/cli/__tests__/keypress-chunks.test.ts deleted file mode 100644 index 797df3cf4..000000000 --- a/agentic/cli/__tests__/keypress-chunks.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { EventEmitter } from 'events'; - -import { segmentKeys, splitCoalescedKeypresses } from '../src/keypress-chunks'; - -describe('segmentKeys', () => { - it('keeps single characters whole', () => { - expect(segmentKeys('a')).toEqual(['a']); - expect(segmentKeys('\r')).toEqual(['\r']); - }); - - it('splits a pasted string into single keys', () => { - expect(segmentKeys('QaAgentLogin!2026\r')).toEqual([...'QaAgentLogin!2026', '\r']); - }); - - it('keeps CSI escape sequences intact', () => { - expect(segmentKeys('\u001b[B')).toEqual(['\u001b[B']); - expect(segmentKeys('\u001b[B\u001b[B\r')).toEqual(['\u001b[B', '\u001b[B', '\r']); - expect(segmentKeys('\u001b[1;5D')).toEqual(['\u001b[1;5D']); - expect(segmentKeys('\u001b[3~')).toEqual(['\u001b[3~']); - }); - - it('keeps two-char alt sequences intact', () => { - expect(segmentKeys('\u001bb')).toEqual(['\u001bb']); - expect(segmentKeys('\u001b')).toEqual(['\u001b']); - }); - - it('splits mixed text and escapes', () => { - expect(segmentKeys('ab\u001b[Ac')).toEqual(['a', 'b', '\u001b[A', 'c']); - }); -}); - -describe('splitCoalescedKeypresses', () => { - function fakePrompter() { - const input = new EventEmitter(); - const received: string[] = []; - const kp = { - input, - listeners: {} as Record, - dataHandler: (key: string) => received.push(key) - }; - input.on('data', kp.dataHandler); - return { prompter: { keypress: kp } as any, kp, input, received }; - } - - it('feeds coalesced chunks to the handler one key at a time', () => { - const { prompter, input, received } = fakePrompter(); - splitCoalescedKeypresses(prompter); - input.emit('data', 'n!2026\r'); - expect(received).toEqual(['n', '!', '2', '0', '2', '6', '\r']); - }); - - it('delivers a chunk whole when it is itself a registered key', () => { - const { prompter, kp, input, received } = fakePrompter(); - kp.listeners['\u001b[B'] = [(): void => undefined]; - splitCoalescedKeypresses(prompter); - input.emit('data', '\u001b[B'); - expect(received).toEqual(['\u001b[B']); - }); - - it('replaces dataHandler so keypress.destroy removes the wrapper', () => { - const { prompter, kp, input } = fakePrompter(); - const original = kp.dataHandler; - splitCoalescedKeypresses(prompter); - expect(kp.dataHandler).not.toBe(original); - expect(input.listeners('data')).toEqual([kp.dataHandler]); - }); - - it('is a no-op without a keypress handler (noTty)', () => { - expect(() => splitCoalescedKeypresses({ keypress: null } as any)).not.toThrow(); - }); -}); diff --git a/agentic/cli/__tests__/stores.test.ts b/agentic/cli/__tests__/stores.test.ts index ff559b134..cefebfc87 100644 --- a/agentic/cli/__tests__/stores.test.ts +++ b/agentic/cli/__tests__/stores.test.ts @@ -1,3 +1,4 @@ +import { ConfigStore } from 'appstash'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -27,50 +28,86 @@ const session: AccountSession = { signedInAt: 1754000000000 }; +const store = (): ConfigStore => loadConfig(home).store; + describe('config', () => { - it('exposes account and backend file paths under /agent', () => { + it('shares the constructive stash rather than an agent-specific directory', () => { + const config = loadConfig(home); + expect(fs.existsSync(path.join(config.dirs.stash.config, 'agent', 'account.json'))).toBe(false); + saveSession(config.store, session); + // credentials land in the shared stash root, not an agent-only subdirectory + expect(fs.existsSync(path.join(config.dirs.stash.config, 'credentials.json'))).toBe(true); + }); + + it('imports a legacy account.json + backend-config.json once, then moves them aside', () => { const config = loadConfig(home); - expect(config.accountFile).toBe(path.join(config.dirs.stash.config, 'agent', 'account.json')); - expect(config.backendFile).toBe(path.join(config.dirs.stash.config, 'agent', 'backend-config.json')); - expect(fs.existsSync(path.dirname(config.accountFile))).toBe(true); + const legacyDir = path.join(config.dirs.stash.config, 'agent'); + fs.mkdirSync(legacyDir, { recursive: true }); + const accountFile = path.join(legacyDir, 'account.json'); + const backendFile = path.join(legacyDir, 'backend-config.json'); + fs.writeFileSync( + accountFile, + JSON.stringify({ + userId: 'user-1', + email: 'dev@example.com', + token: 'access-token', + encrypted: false, + apiKey: 'cnc_live_sk_abc', + keyId: 'key-1', + signedInAt: 1754000000000 + }) + ); + fs.writeFileSync(backendFile, JSON.stringify(BACKEND_PRESETS.devnet)); + + const migrated = loadConfig(home); + expect(loadBackendConfig(migrated.store)).toEqual(BACKEND_PRESETS.devnet); + expect(loadSession(migrated.store)).toMatchObject({ userId: 'user-1', accessToken: 'access-token' }); + expect(fs.existsSync(accountFile)).toBe(false); + expect(fs.existsSync(`${accountFile}.migrated`)).toBe(true); + expect(fs.existsSync(`${backendFile}.migrated`)).toBe(true); }); }); describe('account-store', () => { - it('round-trips a session and keeps the airpage StoredSession shape on disk', () => { - const file = loadConfig(home).accountFile; - saveSession(file, session); - expect(loadSession(file)).toEqual(session); - const stored = JSON.parse(fs.readFileSync(file, 'utf8')); - expect(stored.token).toBe('access-token'); - expect(stored.encrypted).toBe(false); - expect(stored.accessToken).toBeUndefined(); + it('round-trips a session through the shared store', () => { + const s = store(); + saveSession(s, session); + expect(loadSession(s)).toEqual(session); }); - it('writes the session file with mode 0600', () => { - const file = loadConfig(home).accountFile; - saveSession(file, session); - expect(fs.statSync(file).mode & 0o777).toBe(0o600); + it('files the session under the active backend context, keeping backends independent', () => { + const s = store(); + saveBackendConfig(s, BACKEND_PRESETS.devnet); + saveSession(s, session); + saveBackendConfig(s, BACKEND_PRESETS.localnet); + expect(loadSession(s)).toBeNull(); + saveBackendConfig(s, BACKEND_PRESETS.devnet); + expect(loadSession(s)).toEqual(session); }); - it('returns null when no session file exists', () => { - expect(loadSession(loadConfig(home).accountFile)).toBeNull(); + it('defaults to the localnet context when no backend was chosen yet', () => { + const s = store(); + saveSession(s, session); + expect(s.getCurrentContext()?.name).toBe('localnet'); }); - it('moves a corrupt session file aside and returns null', () => { - const file = loadConfig(home).accountFile; - fs.writeFileSync(file, 'not json'); - expect(loadSession(file)).toBeNull(); - expect(fs.existsSync(`${file}.bak`)).toBe(true); - expect(fs.existsSync(file)).toBe(false); + it('writes credentials with mode 0600', () => { + const s = store(); + saveSession(s, session); + const file = path.join(loadConfig(home).dirs.stash.config, 'credentials.json'); + expect(fs.statSync(file).mode & 0o777).toBe(0o600); + }); + + it('returns null when nothing is stored', () => { + expect(loadSession(store())).toBeNull(); }); - it('clearSession removes the file and tolerates a missing one', () => { - const file = loadConfig(home).accountFile; - saveSession(file, session); - clearSession(file); - expect(fs.existsSync(file)).toBe(false); - expect(() => clearSession(file)).not.toThrow(); + it('clearSession removes the credentials and tolerates a signed-out store', () => { + const s = store(); + saveSession(s, session); + clearSession(s); + expect(loadSession(s)).toBeNull(); + expect(() => clearSession(s)).not.toThrow(); }); }); @@ -85,12 +122,21 @@ describe('backend-store', () => { expect(BACKEND_PRESETS.devnet.authEndpoint).toBe('https://auth.launchql.dev/graphql'); }); - it('round-trips a backend config and returns null for missing or invalid files', () => { - const file = loadConfig(home).backendFile; - expect(loadBackendConfig(file)).toBeNull(); - saveBackendConfig(file, BACKEND_PRESETS.devnet); - expect(loadBackendConfig(file)).toEqual(BACKEND_PRESETS.devnet); - fs.writeFileSync(file, '{"apiEndpoint":"x"}'); - expect(loadBackendConfig(file)).toBeNull(); + it('round-trips a backend config and returns null before one is chosen', () => { + const s = store(); + expect(loadBackendConfig(s)).toBeNull(); + expect(saveBackendConfig(s, BACKEND_PRESETS.devnet)).toBe('devnet'); + expect(loadBackendConfig(s)).toEqual(BACKEND_PRESETS.devnet); + }); + + it('files endpoints matching no preset under the custom context', () => { + const s = store(); + const custom = { + apiEndpoint: 'https://api.example.com/graphql', + authEndpoint: 'https://auth.example.com/graphql', + modulesEndpoint: 'https://modules.example.com/graphql' + }; + expect(saveBackendConfig(s, custom)).toBe('custom'); + expect(loadBackendConfig(s)).toEqual(custom); }); }); diff --git a/agentic/cli/package.json b/agentic/cli/package.json index 00238f3a3..8d767b18f 100644 --- a/agentic/cli/package.json +++ b/agentic/cli/package.json @@ -2,7 +2,7 @@ "name": "@agentic-kit/cli", "version": "0.8.2", "author": "Dan Lynch ", - "description": "agent — the pi coding agent with the Constructive harness baked in, purely a shell", + "description": "agent \u2014 the pi coding agent with the Constructive harness baked in, purely a shell", "main": "index.js", "module": "esm/index.js", "types": "index.d.ts", @@ -36,6 +36,7 @@ "@agentic-kit/pi": "workspace:*", "@constructive-io/sdk": "workspace:^", "@earendil-works/pi-coding-agent": "0.79.6", + "appstash": "^0.8.0", "inquirerer": "^4.9.3" }, "keywords": [ diff --git a/agentic/cli/src/account-store.ts b/agentic/cli/src/account-store.ts index 7e4328daf..95ccad585 100644 --- a/agentic/cli/src/account-store.ts +++ b/agentic/cli/src/account-store.ts @@ -1,19 +1,6 @@ -import * as fs from 'fs'; -import * as path from 'path'; +import { ConfigStore } from 'appstash'; -/** On-disk shape, kept identical to airpage's StoredSession for parity. */ -export interface StoredSession { - userId: string; - email: string; - token: string; - /** Always false in the CLI: the token is stored plaintext, protected by file mode 0600. */ - encrypted: boolean; - accessTokenExpiresAt?: string; - apiKey?: string; - keyId?: string; - apiKeyExpiresAt?: string; - signedInAt: number; -} +import { BACKEND_PRESETS, saveBackendConfig } from './backend-store'; export interface AccountSession { userId: string; @@ -26,71 +13,57 @@ export interface AccountSession { signedInAt: number; } -function toSession(stored: StoredSession): AccountSession { +/** + * Sessions live in the shared Constructive stash, as the credentials of the + * active context (the chosen backend). The store owns the file layout, the + * atomic 0600 writes and the at-rest encoding; this module only maps between + * its `ContextCredentials` and the session shape the CLI passes around. + */ +function currentContextName(store: ConfigStore): string | null { + return store.getCurrentContext()?.name ?? null; +} + +/** The active context, defaulting to localnet the first time one is needed. */ +function ensureContextName(store: ConfigStore): string { + return currentContextName(store) ?? saveBackendConfig(store, BACKEND_PRESETS.localnet); +} + +export function loadSession(store: ConfigStore, context?: string): AccountSession | null { + const contextName = context ?? currentContextName(store); + if (!contextName) return null; + const creds = store.getCredentials(contextName); + if (!creds?.token || !creds.userId) return null; return { - userId: stored.userId, - email: stored.email, - accessToken: stored.token, - accessTokenExpiresAt: stored.accessTokenExpiresAt, - apiKey: stored.apiKey, - keyId: stored.keyId, - apiKeyExpiresAt: stored.apiKeyExpiresAt, - signedInAt: stored.signedInAt + userId: creds.userId, + email: creds.email ?? '', + accessToken: creds.token, + accessTokenExpiresAt: creds.expiresAt, + apiKey: creds.apiKey, + keyId: creds.keyId, + apiKeyExpiresAt: creds.apiKeyExpiresAt, + signedInAt: creds.signedInAt ?? 0 }; } -function toStored(session: AccountSession): StoredSession { - return { +/** + * `context` names the backend the session belongs to; it does not have to exist + * as a context yet, so a sign-in can be filed before the backend is committed + * and a failed sign-in leaves nothing behind. + */ +export function saveSession(store: ConfigStore, session: AccountSession, context?: string): void { + store.setCredentials(context ?? ensureContextName(store), { + token: session.accessToken, + expiresAt: session.accessTokenExpiresAt, userId: session.userId, email: session.email, - token: session.accessToken, - encrypted: false, - accessTokenExpiresAt: session.accessTokenExpiresAt, apiKey: session.apiKey, keyId: session.keyId, apiKeyExpiresAt: session.apiKeyExpiresAt, signedInAt: session.signedInAt - }; -} - -export function loadSession(file: string): AccountSession | null { - let raw: string; - try { - raw = fs.readFileSync(file, 'utf8'); - } catch (err: any) { - if (err?.code === 'ENOENT') return null; - throw err; - } - let stored: StoredSession; - try { - stored = JSON.parse(raw) as StoredSession; - } catch { - try { - fs.renameSync(file, `${file}.bak`); - } catch { - /* best effort */ - } - return null; - } - if (!stored || typeof stored.token !== 'string' || typeof stored.userId !== 'string') { - return null; - } - return toSession(stored); -} - -export function saveSession(file: string, session: AccountSession): void { - const dir = path.dirname(file); - fs.mkdirSync(dir, { recursive: true }); - const tmp = path.join(dir, `.${path.basename(file)}.tmp`); - fs.writeFileSync(tmp, JSON.stringify(toStored(session), null, 2) + '\n', { mode: 0o600 }); - fs.renameSync(tmp, file); - fs.chmodSync(file, 0o600); + }); } -export function clearSession(file: string): void { - try { - fs.unlinkSync(file); - } catch (err: any) { - if (err?.code !== 'ENOENT') throw err; - } +export function clearSession(store: ConfigStore, context?: string): void { + const contextName = context ?? currentContextName(store); + if (contextName) store.removeCredentials(contextName); } diff --git a/agentic/cli/src/auth.ts b/agentic/cli/src/auth.ts index c7f358121..a591b86e4 100644 --- a/agentic/cli/src/auth.ts +++ b/agentic/cli/src/auth.ts @@ -1,4 +1,5 @@ import { auth } from '@constructive-io/sdk'; +import { ConfigStore } from 'appstash'; import { AccountSession, clearSession, loadSession, saveSession } from './account-store'; import { @@ -23,7 +24,9 @@ interface AuthRecord { export type ApiKeyRefreshStatus = 'ok' | 'reminted' | 'reauth-required' | 'unavailable' | 'signed-out'; const SELECT = { - result: { select: { userId: true, accessToken: true, accessTokenExpiresAt: true } } + result: { + select: { userId: true, accessToken: true, accessTokenExpiresAt: true } + } } as const; const CREATE_KEY_SELECT = { @@ -33,7 +36,10 @@ const CREATE_KEY_SELECT = { const REVOKE_KEY_SELECT = { result: true } as const; function authedClient(endpoint: string, bearer: string): AuthClient { - return auth.createClient({ endpoint, headers: { Authorization: `Bearer ${bearer}` } }); + return auth.createClient({ + endpoint, + headers: { Authorization: `Bearer ${bearer}` } + }); } async function mint(client: AuthClient): Promise { @@ -54,31 +60,41 @@ async function revoke(client: AuthClient, keyId: string): Promise { * degrades to 'reauth-required' when attempted cold. */ export async function refreshApiKeyIfNeeded({ - accountFile, + store, + context, authEndpoint }: { - accountFile: string; + store: ConfigStore; + /** Backend context the session is filed under; defaults to the active one. */ + context?: string; authEndpoint: string; }): Promise { - const session = loadSession(accountFile); + const session = loadSession(store, context); if (!session) return 'signed-out'; const hasKey = !!session.apiKey; - const due = needsRemint({ apiKeyExpiresAt: session.apiKeyExpiresAt, now: Date.now() }); + const due = needsRemint({ + apiKeyExpiresAt: session.apiKeyExpiresAt, + now: Date.now() + }); if (hasKey && !due) return 'ok'; const client = authedClient(authEndpoint, session.accessToken); try { const minted = await remintApiKey({ currentKeyId: session.keyId, - revoke: keyId => revoke(client, keyId), + revoke: (keyId) => revoke(client, keyId), mint: () => mint(client) }); - saveSession(accountFile, { - ...session, - apiKey: minted.apiKey, - keyId: minted.keyId, - apiKeyExpiresAt: minted.apiKeyExpiresAt - }); + saveSession( + store, + { + ...session, + apiKey: minted.apiKey, + keyId: minted.keyId, + apiKeyExpiresAt: minted.apiKeyExpiresAt + }, + context + ); return 'reminted'; } catch (err) { const kind = classifyApiKeyError(err); @@ -89,12 +105,14 @@ export async function refreshApiKeyIfNeeded({ } export async function signIn({ - accountFile, + store, + context, authEndpoint, email, password }: { - accountFile: string; + store: ConfigStore; + context?: string; authEndpoint: string; email: string; password: string; @@ -119,27 +137,34 @@ export async function signIn({ throw new Error('Authentication returned no access token (MFA may be required).'); } - saveSession(accountFile, { - userId: record.userId, - email: trimmedEmail, - accessToken: record.accessToken, - ...(record.accessTokenExpiresAt ? { accessTokenExpiresAt: record.accessTokenExpiresAt } : {}), - signedInAt: Date.now() - }); + saveSession( + store, + { + userId: record.userId, + email: trimmedEmail, + accessToken: record.accessToken, + ...(record.accessTokenExpiresAt ? { accessTokenExpiresAt: record.accessTokenExpiresAt } : {}), + signedInAt: Date.now() + }, + context + ); // Mint the long-lived API key inside the fresh step-up window. Best-effort: a // mint failure leaves a valid signed-in session that lacks a key until re-auth. - await refreshApiKeyIfNeeded({ accountFile, authEndpoint }); - return loadSession(accountFile); + await refreshApiKeyIfNeeded({ store, context, authEndpoint }); + return loadSession(store, context); } export async function signOut({ - accountFile, + store, + context, authEndpoint }: { - accountFile: string; + store: ConfigStore; + /** Backend context the session is filed under; defaults to the active one. */ + context?: string; authEndpoint: string; }): Promise { - const session = loadSession(accountFile); + const session = loadSession(store, context); if (!session) return false; if (session.keyId) { try { @@ -148,6 +173,6 @@ export async function signOut({ console.warn(`[agent] API key revoke on sign-out failed: ${describeAuthError(err, authEndpoint)}`); } } - clearSession(accountFile); + clearSession(store, context); return true; } diff --git a/agentic/cli/src/backend-store.ts b/agentic/cli/src/backend-store.ts index a86df0f29..9f0d2da1d 100644 --- a/agentic/cli/src/backend-store.ts +++ b/agentic/cli/src/backend-store.ts @@ -1,5 +1,4 @@ -import * as fs from 'fs'; -import * as path from 'path'; +import { ConfigStore } from 'appstash'; export interface BackendConfig { apiEndpoint: string; @@ -20,30 +19,53 @@ export const BACKEND_PRESETS: Record = { } }; -export function loadBackendConfig(file: string): BackendConfig | null { - let raw: string; - try { - raw = fs.readFileSync(file, 'utf8'); - } catch (err: any) { - if (err?.code === 'ENOENT') return null; - throw err; - } - let parsed: BackendConfig; - try { - parsed = JSON.parse(raw) as BackendConfig; - } catch { - return null; - } - if (!parsed?.apiEndpoint || !parsed?.authEndpoint || !parsed?.modulesEndpoint) { - return null; +/** Context name used for endpoints that match no preset. */ +export const CUSTOM_CONTEXT = 'custom'; + +/** + * A chosen backend is a named context in the shared store: the preset name when + * the endpoints match one, `custom` otherwise. Credentials hang off the context, + * so signing in against localnet and devnet keeps two independent sessions. + */ +export function contextNameFor(config: BackendConfig): string { + for (const [name, preset] of Object.entries(BACKEND_PRESETS)) { + if ( + preset.apiEndpoint === config.apiEndpoint && + preset.authEndpoint === config.authEndpoint && + preset.modulesEndpoint === config.modulesEndpoint + ) { + return name; + } } - return parsed; + return CUSTOM_CONTEXT; +} + +function toBackendConfig(targets: Record | undefined): BackendConfig | null { + const apiEndpoint = targets?.api?.endpoint; + const authEndpoint = targets?.auth?.endpoint; + const modulesEndpoint = targets?.modules?.endpoint; + if (!apiEndpoint || !authEndpoint || !modulesEndpoint) return null; + return { apiEndpoint, authEndpoint, modulesEndpoint }; +} + +/** Endpoints of the active context, or null when no backend has been chosen. */ +export function loadBackendConfig(store: ConfigStore): BackendConfig | null { + const ctx = store.getCurrentContext(); + if (!ctx) return null; + return toBackendConfig(ctx.targets); } -export function saveBackendConfig(file: string, config: BackendConfig): void { - const dir = path.dirname(file); - fs.mkdirSync(dir, { recursive: true }); - const tmp = path.join(dir, `.${path.basename(file)}.tmp`); - fs.writeFileSync(tmp, JSON.stringify(config, null, 2) + '\n'); - fs.renameSync(tmp, file); +/** Persist the endpoints as a context and make it active. */ +export function saveBackendConfig(store: ConfigStore, config: BackendConfig): string { + const name = contextNameFor(config); + store.createContext(name, { + endpoint: config.apiEndpoint, + targets: { + api: { endpoint: config.apiEndpoint }, + auth: { endpoint: config.authEndpoint }, + modules: { endpoint: config.modulesEndpoint } + } + }); + store.setCurrentContext(name); + return name; } diff --git a/agentic/cli/src/commands.ts b/agentic/cli/src/commands.ts index fb153d9b2..dc9fb6e4b 100644 --- a/agentic/cli/src/commands.ts +++ b/agentic/cli/src/commands.ts @@ -3,9 +3,14 @@ import { Inquirerer } from 'inquirerer'; import { loadSession } from './account-store'; import { signIn, signOut } from './auth'; -import { BACKEND_PRESETS, BackendConfig, loadBackendConfig, saveBackendConfig } from './backend-store'; +import { + BACKEND_PRESETS, + BackendConfig, + contextNameFor, + loadBackendConfig, + saveBackendConfig +} from './backend-store'; import { AgentCliConfig, defaultManifest, saveManifestFile } from './config'; -import { splitCoalescedKeypresses } from './keypress-chunks'; import { assembleSkills } from './skills'; const log = (msg: string) => console.log(`[agent] ${msg}`); @@ -55,14 +60,6 @@ function maskKey(token: string): string { return `${token.slice(0, 6)}...${token.slice(-4)}`; } -function presetNameFor(config: BackendConfig | null): string | undefined { - if (!config) return undefined; - for (const [name, preset] of Object.entries(BACKEND_PRESETS)) { - if (preset.apiEndpoint === config.apiEndpoint) return name; - } - return 'custom'; -} - export async function login(config: AgentCliConfig, argv: Record): Promise { if (!process.stdin.isTTY && !(argv.email && argv.password)) { throw new Error( @@ -70,11 +67,8 @@ export async function login(config: AgentCliConfig, argv: Record { - const backend = loadBackendConfig(config.backendFile) ?? BACKEND_PRESETS.localnet; + const backend = loadBackendConfig(config.store) ?? BACKEND_PRESETS.localnet; const wasSignedIn = await signOut({ - accountFile: config.accountFile, + store: config.store, authEndpoint: backend.authEndpoint }); if (wasSignedIn) log('signed out — API key revoked and session cleared.'); @@ -141,13 +139,13 @@ export async function logout(config: AgentCliConfig): Promise { } export function whoami(config: AgentCliConfig): void { - const session = loadSession(config.accountFile); + const session = loadSession(config.store); if (!session) { log('not signed in — run `agent login`'); process.exitCode = 1; return; } - const backend = loadBackendConfig(config.backendFile); + const backend = loadBackendConfig(config.store); log(`signed in as ${session.email}`); log(`user id: ${session.userId}`); log(`backend: ${backend?.apiEndpoint ?? 'unknown'}`); @@ -157,7 +155,7 @@ export function whoami(config: AgentCliConfig): void { log('API key: none — db tools stay signed out. Run `agent login` to mint one.'); } if (session.accessTokenExpiresAt) log(`access token expires: ${session.accessTokenExpiresAt}`); - log(`session file: ${config.accountFile}`); + log(`context: ${config.store.getCurrentContext()?.name ?? 'none'}`); } export function usage(): void { diff --git a/agentic/cli/src/config.ts b/agentic/cli/src/config.ts index 783f87a40..94970481d 100644 --- a/agentic/cli/src/config.ts +++ b/agentic/cli/src/config.ts @@ -1,8 +1,20 @@ import { HarnessDirs, harnessDirs, SkillsManifest } from '@agentic-kit/harness'; +import { ConfigStore, createConfigStore } from 'appstash'; import * as fs from 'fs'; import * as path from 'path'; +import { AccountSession, saveSession } from './account-store'; +import { BackendConfig, saveBackendConfig } from './backend-store'; + export const DEFAULT_SKILLS_REPO = 'constructive-io/constructive-skills'; + +/** + * Directory identity shared with the csdk CLI and the desktop app, so a sign-in + * here is a sign-in there. `agent` is only the tool's own name (env prefixes, + * help text). + */ +export const STASH_NAME = 'constructive'; +export const TOOL_NAME = 'agent'; export const DEFAULT_SKILLS_PIN = 'main'; /** Layer names understood by the assembler. */ @@ -17,10 +29,8 @@ export interface AgentCliConfig { overlayDir: string; /** Path of the user-editable manifest: `/skills-manifest.json`. */ manifestFile: string; - /** Signed-in platform session: `/agent/account.json`. */ - accountFile: string; - /** Persisted backend endpoints: `/agent/backend-config.json`. */ - backendFile: string; + /** Shared contexts + credentials store (endpoints and signed-in session). */ + store: ConfigStore; manifest: SkillsManifest; skillsRepo: string; skillsPin: string; @@ -38,17 +48,66 @@ export function defaultManifest(): SkillsManifest { }; } +interface LegacyStoredSession { + userId?: string; + email?: string; + token?: string; + accessTokenExpiresAt?: string; + apiKey?: string; + keyId?: string; + apiKeyExpiresAt?: string; + signedInAt?: number; +} + +function readLegacyFile(file: string): T | null { + if (!fs.existsSync(file)) return null; + return JSON.parse(fs.readFileSync(file, 'utf8')) as T; +} + +/** + * Move a pre-shared-store sign-in (`agent/account.json` + + * `agent/backend-config.json`) into the store, once. The originals are renamed + * rather than deleted, so a downgrade still finds them. + */ +export function importLegacyAgentFiles(store: ConfigStore, legacyDir: string): void { + const accountFile = path.join(legacyDir, 'account.json'); + const backendFile = path.join(legacyDir, 'backend-config.json'); + if (!fs.existsSync(accountFile) && !fs.existsSync(backendFile)) return; + + const backend = readLegacyFile(backendFile); + if (backend?.apiEndpoint && backend.authEndpoint && backend.modulesEndpoint) { + saveBackendConfig(store, backend); + } + + const legacy = readLegacyFile(accountFile); + if (legacy?.token && legacy.userId) { + const session: AccountSession = { + userId: legacy.userId, + email: legacy.email ?? '', + accessToken: legacy.token, + accessTokenExpiresAt: legacy.accessTokenExpiresAt, + apiKey: legacy.apiKey, + keyId: legacy.keyId, + apiKeyExpiresAt: legacy.apiKeyExpiresAt, + signedInAt: legacy.signedInAt ?? Date.now() + }; + saveSession(store, session); + } + + for (const file of [accountFile, backendFile]) { + if (fs.existsSync(file)) fs.renameSync(file, `${file}.migrated`); + } +} + export function loadConfig(baseDir?: string): AgentCliConfig { const dirs = harnessDirs('constructive', baseDir); const agentDir = path.join(dirs.stash.data, 'agent'); const overlayDir = path.join(dirs.stash.config, 'skills-overlay'); const manifestFile = path.join(dirs.stash.config, 'skills-manifest.json'); - const accountDir = path.join(dirs.stash.config, 'agent'); - const accountFile = path.join(accountDir, 'account.json'); - const backendFile = path.join(accountDir, 'backend-config.json'); + const store = createConfigStore(TOOL_NAME, { stashName: STASH_NAME, baseDir }); fs.mkdirSync(agentDir, { recursive: true }); fs.mkdirSync(overlayDir, { recursive: true }); - fs.mkdirSync(accountDir, { recursive: true }); + importLegacyAgentFiles(store, path.join(dirs.stash.config, 'agent')); let file: ManifestFile = {}; if (fs.existsSync(manifestFile)) { @@ -60,8 +119,7 @@ export function loadConfig(baseDir?: string): AgentCliConfig { agentDir, overlayDir, manifestFile, - accountFile, - backendFile, + store, manifest: file.manifest ?? defaultManifest(), skillsRepo: process.env.AGENT_SKILLS_REPO ?? file.repo ?? DEFAULT_SKILLS_REPO, skillsPin: process.env.AGENT_SKILLS_PIN ?? file.pin ?? DEFAULT_SKILLS_PIN diff --git a/agentic/cli/src/credentials.ts b/agentic/cli/src/credentials.ts index 06fd1aa43..728f91255 100644 --- a/agentic/cli/src/credentials.ts +++ b/agentic/cli/src/credentials.ts @@ -23,7 +23,7 @@ export function resolveAccount(): ResolvedAccount | null { if (userId && accessToken) { return { userId, accessToken, apiKey: process.env.CONSTRUCTIVE_API_KEY }; } - const session = loadSession(loadConfig(process.env.AGENT_HOME).accountFile); + const session = loadSession(loadConfig(process.env.AGENT_HOME).store); if (!session) return null; return { userId: session.userId, accessToken: session.accessToken, apiKey: session.apiKey }; } @@ -32,7 +32,7 @@ export function resolveBackendConfig(): ResolvedBackendConfig | undefined { const apiEndpoint = process.env.CONSTRUCTIVE_API_ENDPOINT; const modulesEndpoint = process.env.CONSTRUCTIVE_MODULES_ENDPOINT; if (apiEndpoint || modulesEndpoint) return { apiEndpoint, modulesEndpoint }; - const stored = loadBackendConfig(loadConfig(process.env.AGENT_HOME).backendFile); + const stored = loadBackendConfig(loadConfig(process.env.AGENT_HOME).store); if (!stored) return undefined; return { apiEndpoint: stored.apiEndpoint, modulesEndpoint: stored.modulesEndpoint }; } diff --git a/agentic/cli/src/index.ts b/agentic/cli/src/index.ts index a400d0459..505bfefdc 100644 --- a/agentic/cli/src/index.ts +++ b/agentic/cli/src/index.ts @@ -48,8 +48,8 @@ async function run(args: string[]): Promise { materializeDbTools(config, log); // Fire-and-forget: keep the stored API key fresh (<7 days to expiry re-mints) // without ever blocking startup. Only an expired login session gets a line. - const backend = loadBackendConfig(config.backendFile) ?? BACKEND_PRESETS.localnet; - void refreshApiKeyIfNeeded({ accountFile: config.accountFile, authEndpoint: backend.authEndpoint }) + const backend = loadBackendConfig(config.store) ?? BACKEND_PRESETS.localnet; + void refreshApiKeyIfNeeded({ store: config.store, authEndpoint: backend.authEndpoint }) .then((status) => { if (status === 'reauth-required') log('API key expired — run `agent login`'); }) diff --git a/agentic/cli/src/keypress-chunks.ts b/agentic/cli/src/keypress-chunks.ts deleted file mode 100644 index dde77ca70..000000000 --- a/agentic/cli/src/keypress-chunks.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Inquirerer } from 'inquirerer'; - -const ESC = '\u001b'; -const CSI_FINAL = /[a-zA-Z~]/; - -export function segmentKeys(chunk: string): string[] { - if (chunk.length <= 1) return [chunk]; - const keys: string[] = []; - let i = 0; - while (i < chunk.length) { - if (chunk[i] === ESC && i + 1 < chunk.length) { - let j = i + 2; - if (chunk[i + 1] === '[') { - while (j < chunk.length && !CSI_FINAL.test(chunk[j])) j++; - j++; - } - keys.push(chunk.slice(i, Math.min(j, chunk.length))); - i = j; - } else { - keys.push(chunk[i]); - i++; - } - } - return keys; -} - -/** - * inquirerer's TerminalKeypress looks a stdin data chunk up as one exact key, - * so a paste or fast typing (several bytes coalescing into one chunk) is - * silently dropped — a pasted password dies with a bogus auth error. Re-wire - * the data listener to feed the original handler one key at a time, unless the - * whole chunk is itself a registered key (arrow sequences arrive that way). - */ -export function splitCoalescedKeypresses(prompter: Inquirerer): void { - const kp = (prompter as unknown as { keypress?: KeypressInternals }).keypress; - if (!kp?.dataHandler || !kp.input) return; - const original = kp.dataHandler; - kp.input.removeListener('data', original); - const wrapped = (chunk: string): void => { - if (kp.listeners?.[chunk]?.length) { - original(chunk); - return; - } - for (const key of segmentKeys(chunk)) original(key); - }; - kp.dataHandler = wrapped; - kp.input.on('data', wrapped); -} - -interface KeypressInternals { - dataHandler: ((key: string) => void) | null; - listeners: Record; - input: NodeJS.ReadStream; -} diff --git a/agentic/harness/package.json b/agentic/harness/package.json index dadb111d9..7a9a75ca0 100644 --- a/agentic/harness/package.json +++ b/agentic/harness/package.json @@ -29,7 +29,7 @@ "test:watch": "jest --watch" }, "dependencies": { - "appstash": "^0.7.0", + "appstash": "^0.8.0", "semver": "^7.7.2", "tar": "^7.4.3", "zod": "^4.4.3" diff --git a/graphql/server-test/package.json b/graphql/server-test/package.json index e981e9dcb..5b58e1a2b 100644 --- a/graphql/server-test/package.json +++ b/graphql/server-test/package.json @@ -36,7 +36,7 @@ "@types/express": "^5.0.6", "@types/pg": "^8.20.0", "@types/supertest": "^7.2.0", - "appstash": "^0.7.0", + "appstash": "^0.8.0", "gql-ast": "workspace:^", "inquirerer": "^4.9.3", "makage": "^0.3.0", diff --git a/packages/cli/package.json b/packages/cli/package.json index 6e56fa40a..17ac83aa8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -55,7 +55,7 @@ "@pgpmjs/logger": "workspace:^", "@pgpmjs/server-utils": "workspace:^", "@pgpmjs/types": "workspace:^", - "appstash": "^0.7.0", + "appstash": "^0.8.0", "find-and-require-package-json": "^0.9.1", "inquirerer": "^4.9.3", "js-yaml": "^4.1.0", diff --git a/pgpm/cli/package.json b/pgpm/cli/package.json index b4fe521e3..3de344db0 100644 --- a/pgpm/cli/package.json +++ b/pgpm/cli/package.json @@ -56,9 +56,9 @@ "@pgpmjs/transform": "workspace:^", "@pgpmjs/types": "workspace:^", "@pgsql/quotes": "^18.2.1", - "appstash": "^0.7.0", + "appstash": "^0.8.0", "find-and-require-package-json": "^0.9.1", - "genomic": "^5.6.4", + "genomic": "^5.6.5", "inquirerer": "^4.9.3", "js-yaml": "^4.1.0", "pg-cache": "workspace:^", diff --git a/pgpm/core/package.json b/pgpm/core/package.json index f177ea335..a2ef71ca0 100644 --- a/pgpm/core/package.json +++ b/pgpm/core/package.json @@ -58,7 +58,7 @@ "@pgpmjs/transform": "workspace:^", "@pgpmjs/types": "workspace:^", "csv-to-pg": "workspace:^", - "genomic": "^5.6.4", + "genomic": "^5.6.5", "git-changed": "^0.3.0", "glob": "^13.0.6", "parse-package-name": "^1.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 46d00c274..78e29b781 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -160,6 +160,9 @@ importers: '@earendil-works/pi-coding-agent': specifier: 0.79.6 version: 0.79.6(ws@8.20.1)(zod@4.4.3) + appstash: + specifier: ^0.8.0 + version: 0.8.0 inquirerer: specifier: ^4.9.3 version: 4.9.3 @@ -168,8 +171,8 @@ importers: agentic/harness: dependencies: appstash: - specifier: ^0.7.0 - version: 0.7.0 + specifier: ^0.8.0 + version: 0.8.0 semver: specifier: ^7.7.2 version: 7.8.1 @@ -2167,8 +2170,8 @@ importers: specifier: ^7.2.0 version: 7.2.0 appstash: - specifier: ^0.7.0 - version: 0.7.0 + specifier: ^0.8.0 + version: 0.8.0 gql-ast: specifier: workspace:^ version: link:../gql-ast/dist @@ -2384,8 +2387,8 @@ importers: specifier: workspace:^ version: link:../../pgpm/types/dist appstash: - specifier: ^0.7.0 - version: 0.7.0 + specifier: ^0.8.0 + version: 0.8.0 find-and-require-package-json: specifier: ^0.9.1 version: 0.9.1 @@ -2853,14 +2856,14 @@ importers: specifier: ^18.2.1 version: 18.2.1 appstash: - specifier: ^0.7.0 - version: 0.7.0 + specifier: ^0.8.0 + version: 0.8.0 find-and-require-package-json: specifier: ^0.9.1 version: 0.9.1 genomic: - specifier: ^5.6.4 - version: 5.6.4 + specifier: ^5.6.5 + version: 5.6.5 inquirerer: specifier: ^4.9.3 version: 4.9.3 @@ -2951,8 +2954,8 @@ importers: specifier: workspace:^ version: link:../../packages/csv-to-pg/dist genomic: - specifier: ^5.6.4 - version: 5.6.4 + specifier: ^5.6.5 + version: 5.6.5 git-changed: specifier: ^0.3.0 version: 0.3.0 @@ -3650,8 +3653,8 @@ importers: specifier: workspace:^ version: link:../../graphql/types/dist appstash: - specifier: ^0.7.0 - version: 0.7.0 + specifier: ^0.8.0 + version: 0.8.0 gql-ast: specifier: workspace:^ version: link:../../graphql/gql-ast/dist @@ -6812,6 +6815,9 @@ packages: appstash@0.7.1: resolution: {integrity: sha512-q/7IMKVRGOEfiycXUxiJlK/TbunN1LTMs+I9etSVfsX7kNOWU3/VQhkAO/Fh39jAVCZhm9cK/OjBFMM4kh78Ng==} + appstash@0.8.0: + resolution: {integrity: sha512-BGt/TxAwPO6c30df8FnGwy0w3UvH4MBYQza+nDTxbektw6eBFzyCGt7qzBWRiml2YNNW8ebzoKpK2XfXNQkyrw==} + aproba@2.0.0: resolution: {integrity: sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==} @@ -7943,8 +7949,8 @@ packages: resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} engines: {node: '>=18'} - genomic@5.6.4: - resolution: {integrity: sha512-k4wUPBCMn5k7UNuYfYQ0e05yYp99hgo6gG7YToS/62Kr+2Ax4bKnXs2ZWfAB4HyyjTQ36d9Kaj4gfJquuUDcbQ==} + genomic@5.6.5: + resolution: {integrity: sha512-gmCNjYH+nS/moerbcXZiREN9vbK8E4nETlQzLuIuaH0kG7WYV8nKR8ckb1vw/WBXvo1Z8XIBCFvxnPsssiRA6Q==} gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} @@ -14481,6 +14487,8 @@ snapshots: appstash@0.7.1: {} + appstash@0.8.0: {} + aproba@2.0.0: {} arg@4.1.3: {} @@ -15602,9 +15610,9 @@ snapshots: transitivePeerDependencies: - supports-color - genomic@5.6.4: + genomic@5.6.5: dependencies: - appstash: 0.7.1 + appstash: 0.8.0 inquirerer: 4.9.3 gensync@1.0.0-beta.2: {} diff --git a/sdk/constructive-cli/package.json b/sdk/constructive-cli/package.json index f335b3b2b..bec0ff5cc 100644 --- a/sdk/constructive-cli/package.json +++ b/sdk/constructive-cli/package.json @@ -48,7 +48,7 @@ "@agentic-kit/ollama": "workspace:*", "@constructive-io/graphql-query": "workspace:^", "@constructive-io/graphql-types": "workspace:^", - "appstash": "^0.7.0", + "appstash": "^0.8.0", "gql-ast": "workspace:^", "graphql": "16.13.0", "inquirerer": "^4.9.3", diff --git a/sdk/constructive-cli/scripts/generate-sdk.ts b/sdk/constructive-cli/scripts/generate-sdk.ts index adcb6902b..3c52cfca8 100644 --- a/sdk/constructive-cli/scripts/generate-sdk.ts +++ b/sdk/constructive-cli/scripts/generate-sdk.ts @@ -18,6 +18,7 @@ async function main() { orm: true, cli: { toolName: 'csdk', + stashName: 'constructive', entryPoint: true, }, reactQuery: false, diff --git a/sdk/constructive-cli/src/admin/cli/executor.ts b/sdk/constructive-cli/src/admin/cli/executor.ts index 50d150bce..342d05c89 100644 --- a/sdk/constructive-cli/src/admin/cli/executor.ts +++ b/sdk/constructive-cli/src/admin/cli/executor.ts @@ -5,7 +5,9 @@ */ import { createConfigStore } from 'appstash'; import { createClient } from '../orm'; -const store = createConfigStore('csdk'); +const store = createConfigStore('csdk', { + stashName: 'constructive', +}); export const getStore = () => store; export function getClient(contextName?: string) { let ctx = null; diff --git a/sdk/constructive-cli/src/agent/cli/executor.ts b/sdk/constructive-cli/src/agent/cli/executor.ts index 50d150bce..342d05c89 100644 --- a/sdk/constructive-cli/src/agent/cli/executor.ts +++ b/sdk/constructive-cli/src/agent/cli/executor.ts @@ -5,7 +5,9 @@ */ import { createConfigStore } from 'appstash'; import { createClient } from '../orm'; -const store = createConfigStore('csdk'); +const store = createConfigStore('csdk', { + stashName: 'constructive', +}); export const getStore = () => store; export function getClient(contextName?: string) { let ctx = null; diff --git a/sdk/constructive-cli/src/api/cli/executor.ts b/sdk/constructive-cli/src/api/cli/executor.ts index 50d150bce..342d05c89 100644 --- a/sdk/constructive-cli/src/api/cli/executor.ts +++ b/sdk/constructive-cli/src/api/cli/executor.ts @@ -5,7 +5,9 @@ */ import { createConfigStore } from 'appstash'; import { createClient } from '../orm'; -const store = createConfigStore('csdk'); +const store = createConfigStore('csdk', { + stashName: 'constructive', +}); export const getStore = () => store; export function getClient(contextName?: string) { let ctx = null; diff --git a/sdk/constructive-cli/src/auth/cli/executor.ts b/sdk/constructive-cli/src/auth/cli/executor.ts index 50d150bce..342d05c89 100644 --- a/sdk/constructive-cli/src/auth/cli/executor.ts +++ b/sdk/constructive-cli/src/auth/cli/executor.ts @@ -5,7 +5,9 @@ */ import { createConfigStore } from 'appstash'; import { createClient } from '../orm'; -const store = createConfigStore('csdk'); +const store = createConfigStore('csdk', { + stashName: 'constructive', +}); export const getStore = () => store; export function getClient(contextName?: string) { let ctx = null; diff --git a/sdk/constructive-cli/src/cli-commands.ts b/sdk/constructive-cli/src/cli-commands.ts index 036662907..49724d2d0 100644 --- a/sdk/constructive-cli/src/cli-commands.ts +++ b/sdk/constructive-cli/src/cli-commands.ts @@ -8,11 +8,18 @@ import type { CLIOptions, Inquirerer, ParsedArgs } from 'inquirerer'; import { extractFirst, getPackageJson } from 'inquirerer'; -import { getConfigStore } from './config-store'; +import { createConfigStore } from 'appstash'; + import { printSuccess, printError, printKeyValue, printTable } from './utils'; const TOOL_NAME = 'csdk'; +// Every Constructive tool — this CLI, the agent CLI, the desktop app — stores its +// contexts and credentials under the same stash, so one sign-in covers all of them. +const STASH_NAME = 'constructive'; + +const getConfigStore = () => createConfigStore(TOOL_NAME, { stashName: STASH_NAME }); + const usageText = ` csdk @@ -46,7 +53,7 @@ async function handleContextCreate( { type: 'text', name: 'endpoint', message: 'GraphQL endpoint URL', required: true } ]); - const store = getConfigStore(TOOL_NAME); + const store = getConfigStore(); store.createContext(answers.name as string, { endpoint: answers.endpoint as string }); const settings = store.loadSettings(); @@ -59,7 +66,7 @@ async function handleContextCreate( } async function handleContextList() { - const store = getConfigStore(TOOL_NAME); + const store = getConfigStore(); const contexts = store.listContexts(); const settings = store.loadSettings(); @@ -83,7 +90,7 @@ async function handleContextUse( argv: Partial>, prompter: Inquirerer ) { - const store = getConfigStore(TOOL_NAME); + const store = getConfigStore(); const contexts = store.listContexts(); if (contexts.length === 0) { @@ -105,7 +112,7 @@ async function handleContextUse( } async function handleContextCurrent() { - const store = getConfigStore(TOOL_NAME); + const store = getConfigStore(); const ctx = store.getCurrentContext(); if (!ctx) { @@ -121,7 +128,7 @@ async function handleContextDelete( argv: Partial>, prompter: Inquirerer ) { - const store = getConfigStore(TOOL_NAME); + const store = getConfigStore(); const contexts = store.listContexts(); if (contexts.length === 0) { diff --git a/sdk/constructive-cli/src/compute/cli/executor.ts b/sdk/constructive-cli/src/compute/cli/executor.ts index 50d150bce..342d05c89 100644 --- a/sdk/constructive-cli/src/compute/cli/executor.ts +++ b/sdk/constructive-cli/src/compute/cli/executor.ts @@ -5,7 +5,9 @@ */ import { createConfigStore } from 'appstash'; import { createClient } from '../orm'; -const store = createConfigStore('csdk'); +const store = createConfigStore('csdk', { + stashName: 'constructive', +}); export const getStore = () => store; export function getClient(contextName?: string) { let ctx = null; diff --git a/sdk/constructive-cli/src/config-store.ts b/sdk/constructive-cli/src/config-store.ts deleted file mode 100644 index f324ec8fd..000000000 --- a/sdk/constructive-cli/src/config-store.ts +++ /dev/null @@ -1,149 +0,0 @@ -/** - * Config store for the csdk CLI — manages named contexts (endpoint + credentials). - * Uses appstash for XDG-compliant directory resolution. - */ - -import * as fs from 'fs'; -import * as path from 'path'; -import { appstash, resolve } from 'appstash'; - -interface ContextConfig { - name: string; - endpoint: string; - createdAt?: string; - updatedAt?: string; -} - -interface GlobalSettings { - currentContext?: string; -} - -interface ContextCredentials { - token: string; - expiresAt?: string; - refreshToken?: string; -} - -interface Credentials { - tokens: Record; -} - -interface ConfigStore { - loadSettings(): GlobalSettings; - createContext(name: string, opts: { endpoint: string }): void; - listContexts(): ContextConfig[]; - setCurrentContext(name: string): void; - getCurrentContext(): (ContextConfig & { name: string }) | null; - deleteContext(name: string): void; - hasValidCredentials(name: string): boolean; -} - -export function getConfigStore(toolName: string): ConfigStore { - const dirs = appstash(toolName, { ensure: true }); - - function configPath(filename: string): string { - return resolve(dirs, 'config', filename); - } - - function contextsDir(): string { - const dir = resolve(dirs, 'config', 'contexts'); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - return dir; - } - - function loadSettings(): GlobalSettings { - const p = configPath('settings.json'); - if (fs.existsSync(p)) { - try { - return JSON.parse(fs.readFileSync(p, 'utf8')); - } catch { - return {}; - } - } - return {}; - } - - function saveSettings(settings: GlobalSettings): void { - const p = configPath('settings.json'); - const dir = path.dirname(p); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - fs.writeFileSync(p, JSON.stringify(settings, null, 2)); - } - - function loadCredentials(): Credentials { - const p = configPath('credentials.json'); - if (fs.existsSync(p)) { - try { - return JSON.parse(fs.readFileSync(p, 'utf8')); - } catch { - return { tokens: {} }; - } - } - return { tokens: {} }; - } - - return { - loadSettings, - - createContext(name: string, opts: { endpoint: string }): void { - const now = new Date().toISOString(); - const ctx: ContextConfig = { name, endpoint: opts.endpoint, createdAt: now, updatedAt: now }; - const p = path.join(contextsDir(), `${name}.json`); - fs.writeFileSync(p, JSON.stringify(ctx, null, 2)); - }, - - listContexts(): ContextConfig[] { - const dir = contextsDir(); - const files = fs.readdirSync(dir).filter((f) => f.endsWith('.json')); - const contexts: ContextConfig[] = []; - for (const file of files) { - try { - contexts.push(JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'))); - } catch { - // skip - } - } - return contexts; - }, - - setCurrentContext(name: string): void { - const settings = loadSettings(); - settings.currentContext = name; - saveSettings(settings); - }, - - getCurrentContext(): (ContextConfig & { name: string }) | null { - const settings = loadSettings(); - if (!settings.currentContext) return null; - const p = path.join(contextsDir(), `${settings.currentContext}.json`); - if (!fs.existsSync(p)) return null; - try { - return JSON.parse(fs.readFileSync(p, 'utf8')); - } catch { - return null; - } - }, - - deleteContext(name: string): void { - const p = path.join(contextsDir(), `${name}.json`); - if (fs.existsSync(p)) fs.unlinkSync(p); - const settings = loadSettings(); - if (settings.currentContext === name) { - delete settings.currentContext; - saveSettings(settings); - } - }, - - hasValidCredentials(name: string): boolean { - const creds = loadCredentials(); - const ctx = creds.tokens[name]; - if (!ctx || !ctx.token) return false; - if (ctx.expiresAt && new Date(ctx.expiresAt) <= new Date()) return false; - return true; - }, - }; -} diff --git a/sdk/constructive-cli/src/config/cli/executor.ts b/sdk/constructive-cli/src/config/cli/executor.ts index 50d150bce..342d05c89 100644 --- a/sdk/constructive-cli/src/config/cli/executor.ts +++ b/sdk/constructive-cli/src/config/cli/executor.ts @@ -5,7 +5,9 @@ */ import { createConfigStore } from 'appstash'; import { createClient } from '../orm'; -const store = createConfigStore('csdk'); +const store = createConfigStore('csdk', { + stashName: 'constructive', +}); export const getStore = () => store; export function getClient(contextName?: string) { let ctx = null; diff --git a/sdk/constructive-cli/src/infra/cli/executor.ts b/sdk/constructive-cli/src/infra/cli/executor.ts index 50d150bce..342d05c89 100644 --- a/sdk/constructive-cli/src/infra/cli/executor.ts +++ b/sdk/constructive-cli/src/infra/cli/executor.ts @@ -5,7 +5,9 @@ */ import { createConfigStore } from 'appstash'; import { createClient } from '../orm'; -const store = createConfigStore('csdk'); +const store = createConfigStore('csdk', { + stashName: 'constructive', +}); export const getStore = () => store; export function getClient(contextName?: string) { let ctx = null; diff --git a/sdk/constructive-cli/src/modules/cli/executor.ts b/sdk/constructive-cli/src/modules/cli/executor.ts index 50d150bce..342d05c89 100644 --- a/sdk/constructive-cli/src/modules/cli/executor.ts +++ b/sdk/constructive-cli/src/modules/cli/executor.ts @@ -5,7 +5,9 @@ */ import { createConfigStore } from 'appstash'; import { createClient } from '../orm'; -const store = createConfigStore('csdk'); +const store = createConfigStore('csdk', { + stashName: 'constructive', +}); export const getStore = () => store; export function getClient(contextName?: string) { let ctx = null; diff --git a/sdk/constructive-cli/src/objects/cli/executor.ts b/sdk/constructive-cli/src/objects/cli/executor.ts index 50d150bce..342d05c89 100644 --- a/sdk/constructive-cli/src/objects/cli/executor.ts +++ b/sdk/constructive-cli/src/objects/cli/executor.ts @@ -5,7 +5,9 @@ */ import { createConfigStore } from 'appstash'; import { createClient } from '../orm'; -const store = createConfigStore('csdk'); +const store = createConfigStore('csdk', { + stashName: 'constructive', +}); export const getStore = () => store; export function getClient(contextName?: string) { let ctx = null; diff --git a/sdk/constructive-cli/src/usage/cli/executor.ts b/sdk/constructive-cli/src/usage/cli/executor.ts index 50d150bce..342d05c89 100644 --- a/sdk/constructive-cli/src/usage/cli/executor.ts +++ b/sdk/constructive-cli/src/usage/cli/executor.ts @@ -5,7 +5,9 @@ */ import { createConfigStore } from 'appstash'; import { createClient } from '../orm'; -const store = createConfigStore('csdk'); +const store = createConfigStore('csdk', { + stashName: 'constructive', +}); export const getStore = () => store; export function getClient(contextName?: string) { let ctx = null;