diff --git a/agentic/cli/README.md b/agentic/cli/README.md index eac9c12d6a..36b8c8dfab 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__/api-key.test.ts b/agentic/cli/__tests__/api-key.test.ts new file mode 100644 index 0000000000..5176387b9f --- /dev/null +++ b/agentic/cli/__tests__/api-key.test.ts @@ -0,0 +1,135 @@ +import { + API_KEY_NAME, + buildCreateApiKeyInput, + classifyApiKeyError, + MintedApiKey, + needsRemint, + parseMintedKey, + REMINT_THRESHOLD_MS, + remintApiKey +} 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 0000000000..8fb7e111a6 --- /dev/null +++ b/agentic/cli/__tests__/auth.test.ts @@ -0,0 +1,225 @@ +import { ConfigStore } from 'appstash'; +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 store: ConfigStore; + +beforeEach(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-cli-auth-')); + store = loadConfig(home).store; + 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({ + store, + 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(store)).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({ store, authEndpoint: AUTH_ENDPOINT, email: 'dev@example.com', password: 'pw' }); + expect(session.apiKey).toBeUndefined(); + 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({ store, authEndpoint: AUTH_ENDPOINT, email: 'dev@example.com', password: 'pw' }) + ).rejects.toThrow('Authentication returned no access token (MFA may be required).'); + expect(loadSession(store)).toBeNull(); + }); + + it('rejects empty credentials without a network call', async () => { + await expect( + signIn({ store, 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({ store, 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({ store, authEndpoint: AUTH_ENDPOINT })).resolves.toBe('signed-out'); + }); + + it('returns ok for a fresh key without a network call', async () => { + saveSession(store, { + ...baseSession, + apiKey: 'k', + keyId: 'id', + apiKeyExpiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365).toISOString() + }); + 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(store, { + ...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({ store, authEndpoint: AUTH_ENDPOINT })).resolves.toBe('reminted'); + expect(mutation.revokeApiKey).toHaveBeenCalledWith({ input: { keyId: 'old-id' } }, expect.anything()); + expect(loadSession(store)?.apiKey).toBe('cnc_live_sk_new'); + }); + + it('returns reauth-required on a step-up error', async () => { + saveSession(store, baseSession); + mockClient({ + createApiKey: jest.fn(() => failing({ errors: [{ extensions: { code: 'STEP_UP_REQUIRED' } }] })), + revokeApiKey: jest.fn() + }); + 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(store, baseSession); + mockClient({ + createApiKey: jest.fn(() => failing(new Error('boom'))), + revokeApiKey: jest.fn() + }); + await expect(refreshApiKeyIfNeeded({ store, authEndpoint: AUTH_ENDPOINT })).resolves.toBe('unavailable'); + warn.mockRestore(); + }); +}); + +describe('signOut', () => { + it('revokes the key and clears the session', async () => { + saveSession(store, { + 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({ store, authEndpoint: AUTH_ENDPOINT })).resolves.toBe(true); + expect(mutation.revokeApiKey).toHaveBeenCalledWith({ input: { keyId: 'key-1' } }, expect.anything()); + expect(loadSession(store)).toBeNull(); + }); + + it('clears the session even when the revoke fails', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + saveSession(store, { + 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({ 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({ 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 new file mode 100644 index 0000000000..5f178830c2 --- /dev/null +++ b/agentic/cli/__tests__/commands.test.ts @@ -0,0 +1,160 @@ +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({ + store: config.store, + context: 'localnet', + authEndpoint: BACKEND_PRESETS.localnet.authEndpoint, + email: 'dev@example.com', + password: 'pw' + }); + expect(loadBackendConfig(config.store)).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.store)).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.store)).toBeNull(); + }); +}); + +describe('logout', () => { + it('signs out against the stored backend', async () => { + saveBackendConfig(config.store, BACKEND_PRESETS.devnet); + signOutMock.mockResolvedValue(true); + + await logout(config); + + expect(signOutMock).toHaveBeenCalledWith({ + store: config.store, + 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.store, session); + saveBackendConfig(config.store, 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/__tests__/db-tools.test.ts b/agentic/cli/__tests__/db-tools.test.ts index 99ad5e2d2a..4ba9b5f4db 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 }); }); @@ -34,32 +49,88 @@ describe('materializeDbTools', () => { on: () => {} }); expect(registered).toContain('provision_database'); - expect(registered).toHaveLength(16); + expect(registered).toHaveLength(18); }); - 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(); + + saveBackendConfig(config.store, BACKEND_PRESETS.devnet); + saveSession(config.store, { + userId: 'stored-user', + email: 'dev@example.com', + accessToken: 'stored-token', + apiKey: 'stored-key', + signedInAt: 1 + }); + + 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.store, { + 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/__tests__/stores.test.ts b/agentic/cli/__tests__/stores.test.ts new file mode 100644 index 0000000000..cefebfc87e --- /dev/null +++ b/agentic/cli/__tests__/stores.test.ts @@ -0,0 +1,142 @@ +import { ConfigStore } from 'appstash'; +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 +}; + +const store = (): ConfigStore => loadConfig(home).store; + +describe('config', () => { + 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); + 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 through the shared store', () => { + const s = store(); + saveSession(s, session); + expect(loadSession(s)).toEqual(session); + }); + + 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('defaults to the localnet context when no backend was chosen yet', () => { + const s = store(); + saveSession(s, session); + expect(s.getCurrentContext()?.name).toBe('localnet'); + }); + + 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 credentials and tolerates a signed-out store', () => { + const s = store(); + saveSession(s, session); + clearSession(s); + expect(loadSession(s)).toBeNull(); + expect(() => clearSession(s)).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 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 0970c8098a..8d767b18f2 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", @@ -34,7 +34,9 @@ "dependencies": { "@agentic-kit/harness": "workspace:*", "@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 new file mode 100644 index 0000000000..95ccad5853 --- /dev/null +++ b/agentic/cli/src/account-store.ts @@ -0,0 +1,69 @@ +import { ConfigStore } from 'appstash'; + +import { BACKEND_PRESETS, saveBackendConfig } from './backend-store'; + +export interface AccountSession { + userId: string; + email: string; + accessToken: string; + accessTokenExpiresAt?: string; + apiKey?: string; + keyId?: string; + apiKeyExpiresAt?: string; + signedInAt: number; +} + +/** + * 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: creds.userId, + email: creds.email ?? '', + accessToken: creds.token, + accessTokenExpiresAt: creds.expiresAt, + apiKey: creds.apiKey, + keyId: creds.keyId, + apiKeyExpiresAt: creds.apiKeyExpiresAt, + signedInAt: creds.signedInAt ?? 0 + }; +} + +/** + * `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, + apiKey: session.apiKey, + keyId: session.keyId, + apiKeyExpiresAt: session.apiKeyExpiresAt, + signedInAt: session.signedInAt + }); +} + +export function clearSession(store: ConfigStore, context?: string): void { + const contextName = context ?? currentContextName(store); + if (contextName) store.removeCredentials(contextName); +} diff --git a/agentic/cli/src/api-key.ts b/agentic/cli/src/api-key.ts new file mode 100644 index 0000000000..26abab0827 --- /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 0000000000..7f5114fa2c --- /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 0000000000..a591b86e45 --- /dev/null +++ b/agentic/cli/src/auth.ts @@ -0,0 +1,178 @@ +import { auth } from '@constructive-io/sdk'; +import { ConfigStore } from 'appstash'; + +import { AccountSession, clearSession, loadSession, saveSession } from './account-store'; +import { + API_KEY_YEARS, + buildCreateApiKeyInput, + classifyApiKeyError, + MintedApiKey, + needsRemint, + parseMintedKey, + remintApiKey +} from './api-key'; +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({ + store, + context, + authEndpoint +}: { + store: ConfigStore; + /** Backend context the session is filed under; defaults to the active one. */ + context?: string; + authEndpoint: string; +}): Promise { + const session = loadSession(store, context); + 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( + store, + { + ...session, + apiKey: minted.apiKey, + keyId: minted.keyId, + apiKeyExpiresAt: minted.apiKeyExpiresAt + }, + context + ); + 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({ + store, + context, + authEndpoint, + email, + password +}: { + store: ConfigStore; + context?: 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( + 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({ store, context, authEndpoint }); + return loadSession(store, context); +} + +export async function signOut({ + store, + context, + authEndpoint +}: { + store: ConfigStore; + /** Backend context the session is filed under; defaults to the active one. */ + context?: string; + authEndpoint: string; +}): Promise { + const session = loadSession(store, context); + 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(store, context); + return true; +} diff --git a/agentic/cli/src/backend-store.ts b/agentic/cli/src/backend-store.ts new file mode 100644 index 0000000000..9f0d2da1d4 --- /dev/null +++ b/agentic/cli/src/backend-store.ts @@ -0,0 +1,71 @@ +import { ConfigStore } from 'appstash'; + +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' + } +}; + +/** 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 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); +} + +/** 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 c4938a6ffa..dc9fb6e4b7 100644 --- a/agentic/cli/src/commands.ts +++ b/agentic/cli/src/commands.ts @@ -1,5 +1,15 @@ +import { deriveSubdomainEndpoint } from '@agentic-kit/pi'; import { Inquirerer } from 'inquirerer'; +import { loadSession } from './account-store'; +import { signIn, signOut } from './auth'; +import { + BACKEND_PRESETS, + BackendConfig, + contextNameFor, + loadBackendConfig, + saveBackendConfig +} from './backend-store'; import { AgentCliConfig, defaultManifest, saveManifestFile } from './config'; import { assembleSkills } from './skills'; @@ -45,6 +55,109 @@ 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)}`; +} + +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.store); + 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: saved ? contextNameFor(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}`); + } + + // File the session under the backend's context name, then commit the backend + // itself: a failed sign-in must leave neither behind. + const contextName = contextNameFor(backend); + const session = await signIn({ + store: config.store, + context: contextName, + authEndpoint: backend.authEndpoint, + email: String(answers.email ?? ''), + password: String(answers.password ?? '') + }); + saveBackendConfig(config.store, 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 in context ${contextName}`); + } finally { + prompter.close(); + } +} + +export async function logout(config: AgentCliConfig): Promise { + const backend = loadBackendConfig(config.store) ?? BACKEND_PRESETS.localnet; + const wasSignedIn = await signOut({ + store: config.store, + 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.store); + if (!session) { + log('not signed in — run `agent login`'); + process.exitCode = 1; + return; + } + const backend = loadBackendConfig(config.store); + 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(`context: ${config.store.getCurrentContext()?.name ?? 'none'}`); +} + export function usage(): void { console.log(`agent — the pi coding agent with the Constructive harness baked in @@ -52,6 +165,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/config.ts b/agentic/cli/src/config.ts index 626078a24b..94970481d5 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,6 +29,8 @@ export interface AgentCliConfig { overlayDir: string; /** Path of the user-editable manifest: `/skills-manifest.json`. */ manifestFile: string; + /** Shared contexts + credentials store (endpoints and signed-in session). */ + store: ConfigStore; manifest: SkillsManifest; skillsRepo: string; skillsPin: string; @@ -34,13 +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 store = createConfigStore(TOOL_NAME, { stashName: STASH_NAME, baseDir }); fs.mkdirSync(agentDir, { recursive: true }); fs.mkdirSync(overlayDir, { recursive: true }); + importLegacyAgentFiles(store, path.join(dirs.stash.config, 'agent')); let file: ManifestFile = {}; if (fs.existsSync(manifestFile)) { @@ -52,6 +119,7 @@ export function loadConfig(baseDir?: string): AgentCliConfig { agentDir, overlayDir, manifestFile, + 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 new file mode 100644 index 0000000000..728f912553 --- /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).store); + 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).store); + 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 bfa4a5f976..790d139609 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 95415ceb0a..505bfefdcd 100644 --- a/agentic/cli/src/index.ts +++ b/agentic/cli/src/index.ts @@ -1,5 +1,7 @@ #!/usr/bin/env node -import { init, skillsList, skillsUpdate, usage } from './commands'; +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'; import { assembleSkills } from './skills'; @@ -23,6 +25,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); @@ -32,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.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`'); + }) + .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. diff --git a/agentic/harness/__tests__/default-source.test.ts b/agentic/harness/__tests__/default-source.test.ts new file mode 100644 index 0000000000..814a2a5d91 --- /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/__tests__/gating.test.ts b/agentic/harness/__tests__/gating.test.ts index d6d5aebee7..e7a05c9305 100644 --- a/agentic/harness/__tests__/gating.test.ts +++ b/agentic/harness/__tests__/gating.test.ts @@ -170,6 +170,52 @@ 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); + }); + + 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', () => { @@ -217,4 +263,33 @@ 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/); + }); + + 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/package.json b/agentic/harness/package.json index dadb111d9d..7a9a75ca0b 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/agentic/harness/src/gating/confirm-gate.ts b/agentic/harness/src/gating/confirm-gate.ts index 6abdc453f2..07d2a2ed5b 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) { @@ -115,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 0e951db9de..7c446ab997 100644 --- a/agentic/harness/src/gating/prompts.ts +++ b/agentic/harness/src/gating/prompts.ts @@ -12,6 +12,8 @@ export const MUTATING_DB_TOOLS = new Set([ 'update_template', 'delete_template', 'add_records', + 'manage_entity_types', + 'create_api_key', 'run_codegen', ]); @@ -181,6 +183,38 @@ 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 '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?', diff --git a/agentic/harness/src/index.ts b/agentic/harness/src/index.ts index 31edacaef9..5f8dbe1642 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 0000000000..2310d9ad33 --- /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, + }; +} 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 0000000000..dec8adcfea --- /dev/null +++ b/agentic/pi/__tests__/create-api-key.test.ts @@ -0,0 +1,363 @@ +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, + existingScopeRow: 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 }), + }), + }, + principalEntity: { + findFirst: jest.fn().mockReturnValue({ + unwrap: async (): Promise => ({ principalEntity: existingScopeRow }), + }), + }, + 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-row', name: 'bot', userId: 'prin-user' }); + 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('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-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-user'); + expect(client.principalEntity.findFirst).toHaveBeenCalledWith( + 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 () => { + useContext(); + mockGetHost.mockReturnValue(makeHost() as never); + mockToken.mockResolvedValue({ token: 'tok' }); + global.fetch = mockFetchWith(true) as never; + const client = makeClient( + [MINTED], + { id: 'prin-row', name: 'bot', userId: 'prin-user', 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-row', name: 'bot', userId: 'prin-user', 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(); + 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', + cwd: '/tmp/project', + 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({ + databaseId: 'db-1', + databaseName: 'demo', + apiEndpoint: 'http://api.localhost:6464/graphql', + }); + 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 0de3d4ff5e..75d4b492ef 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 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(16); + expect(registered).toHaveLength(18); expect(registered).toEqual( expect.arrayContaining([ 'provision_database', @@ -67,6 +67,8 @@ describe('dbTools extension', () => { 'delete_field', 'add_policies', 'add_records', + 'manage_entity_types', + 'create_api_key', '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 0000000000..445508363e --- /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/context.ts b/agentic/pi/src/context.ts index 870486923d..3cd697f8eb 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 d8a9156dae..055ae0e0da 100644 --- a/agentic/pi/src/host.ts +++ b/agentic/pi/src/host.ts @@ -55,6 +55,31 @@ 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; + /** 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; @@ -62,6 +87,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. */ @@ -77,6 +108,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?(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. + */ + 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 d35bb6ad3d..b7d02bf7c8 100644 --- a/agentic/pi/src/index.ts +++ b/agentic/pi/src/index.ts @@ -6,7 +6,9 @@ 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'; import { provisionBlueprintTool } from './tools/provision-blueprint'; import { provisionDatabaseTool } from './tools/provision-database'; @@ -36,6 +38,8 @@ export const dbTools: ExtensionFactory = (pi) => { pi.registerTool(updateTemplateTool); pi.registerTool(deleteTemplateTool); pi.registerTool(addRecordsTool); + pi.registerTool(manageEntityTypesTool); + pi.registerTool(createApiKeyTool); pi.registerTool(runCodegenTool); const gate = createConfirmGate({ @@ -55,6 +59,7 @@ export function createDbTools(host: PiToolsHost): ExtensionFactory { export { type ConfirmGate, type ConfirmGateDeps, createConfirmGate } from './confirm-gate'; export { + deriveSubdomainEndpoint, type ModulesClient, type ProjectContext, resolveDataToken, diff --git a/agentic/pi/src/provision-database/credential.ts b/agentic/pi/src/provision-database/credential.ts index afa2e646d1..eeada90c08 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/create-api-key.ts b/agentic/pi/src/tools/create-api-key.ts new file mode 100644 index 0000000000..b7481d400d --- /dev/null +++ b/agentic/pi/src/tools/create-api-key.ts @@ -0,0 +1,288 @@ +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 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 new Set((type?.inputFields ?? []).map((f) => f.name)); +} + +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) { + const fields = await createPrincipalInputFields(authEndpoint); + const missing = [ + ...(params.entity_ids?.length && !fields.has('entityIds') ? ['entityIds'] : []), + ...(params.read_only === true && !fields.has('isReadOnly') ? ['isReadOnly'] : []), + ]; + if (missing.length) { + return fail( + `This deployment does not support scoping a principal at create time (no ${missing.join(', ')} 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, userId: true, isReadOnly: true }, + }) + .unwrap(); + + // createApiKey (like createPrincipal.result) takes the principal's + // identity userId; principal_entity.principalId references the row id. + let principalId = existing.principal?.userId; + if (existing.principal && 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 (existing.principal) { + const scopeRow = await dbAuth.principalEntity + .findFirst({ + where: { principalId: { equalTo: existing.principal.id } }, + select: { id: true }, + }) + .unwrap(); + if (scopeRow.principalEntity || existing.principal?.isReadOnly) { + return fail( + `Principal "${principalName}" already exists with a narrower scope (entity-scoped or read-only), so a key minted under it would not act as the signed-in user. No key was minted. Use a new principal_name for an unscoped key.`, + ); + } + } + 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, databaseName, apiEndpoint }); + 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, + cwd: ctx.cwd, + 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.'); + } + }, +}; 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 0000000000..372e12be7a --- /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 } }; + } + }, +}; diff --git a/agentic/pi/src/tools/provision-database.ts b/agentic/pi/src/tools/provision-database.ts index e917dc7639..57d139d7e6 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( diff --git a/graphql/codegen/src/__tests__/codegen/cli-generator.test.ts b/graphql/codegen/src/__tests__/codegen/cli-generator.test.ts index 16e6e82622..5fd6e84ffa 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 a4cc2e0ede..15b18bcefe 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 1c04bab38b..9d2b40c519 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 9bf444c25d..d6e84f5e8c 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 2a589dcd87..a3ed3a11f5 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 14261fb1bb..e2aaf3b214 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/graphql/server-test/package.json b/graphql/server-test/package.json index e981e9dcb5..5b58e1a2b0 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 6e56fa40ab..17ac83aa82 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 747647dfdf..3de344db0b 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.2", + "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 654369311b..a2ef71ca09 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.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 d8cea89353..78e29b781a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -154,9 +154,15 @@ 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) + appstash: + specifier: ^0.8.0 + version: 0.8.0 inquirerer: specifier: ^4.9.3 version: 4.9.3 @@ -165,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 @@ -2164,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 @@ -2381,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 @@ -2850,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.2 - version: 5.6.2 + specifier: ^5.6.5 + version: 5.6.5 inquirerer: specifier: ^4.9.3 version: 4.9.3 @@ -2948,8 +2954,8 @@ importers: specifier: workspace:^ version: link:../../packages/csv-to-pg/dist genomic: - specifier: ^5.6.2 - version: 5.6.2 + specifier: ^5.6.5 + version: 5.6.5 git-changed: specifier: ^0.3.0 version: 0.3.0 @@ -3647,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 @@ -6809,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==} @@ -7940,8 +7949,8 @@ packages: resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} engines: {node: '>=18'} - genomic@5.6.2: - resolution: {integrity: sha512-y2LK1KQjeZZ4WT0DEQhjTxMNs+hsoZTclIqdnU5Xo3Ie8phDB6ynw8Sk2NLtELL7H3Q26tvZBJkckIkaSa0Lag==} + 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==} @@ -8393,9 +8402,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==} @@ -14481,6 +14487,8 @@ snapshots: appstash@0.7.1: {} + appstash@0.8.0: {} + aproba@2.0.0: {} arg@4.1.3: {} @@ -15602,10 +15610,10 @@ snapshots: transitivePeerDependencies: - supports-color - genomic@5.6.2: + genomic@5.6.5: dependencies: - appstash: 0.7.0 - inquirerer: 4.9.1 + appstash: 0.8.0 + inquirerer: 4.9.3 gensync@1.0.0-beta.2: {} @@ -16290,13 +16298,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 diff --git a/sdk/constructive-cli/package.json b/sdk/constructive-cli/package.json index f335b3b2bd..bec0ff5ccc 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 adcb6902b0..3c52cfca8d 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 50d150bce8..342d05c892 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 50d150bce8..342d05c892 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 50d150bce8..342d05c892 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 50d150bce8..342d05c892 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 0366629079..49724d2d0a 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 50d150bce8..342d05c892 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 f324ec8fde..0000000000 --- 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 50d150bce8..342d05c892 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 50d150bce8..342d05c892 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 50d150bce8..342d05c892 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 50d150bce8..342d05c892 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 50d150bce8..342d05c892 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;