From da9d96e358fb8dabe44f403b478d12ac2c3a9b9d Mon Sep 17 00:00:00 2001 From: Krish Parekh Date: Wed, 28 Oct 2026 18:49:49 +0530 Subject: [PATCH 1/9] feat(connect): create and update a Connect app from one.connect.ts (v2.10.0) Co-Authored-By: Claude Opus 5.5 --- README.md | 11 ++ package-lock.json | 4 +- package.json | 2 +- skills/one/SKILL.md | 9 ++ src/cli.ts | 29 +++++ src/commands/connect.ts | 212 +++++++++++++++++++++++++++++++ src/lib/api.ts | 62 +++++++++ src/lib/browser.test.ts | 19 ++- src/lib/browser.ts | 13 ++ src/lib/connect-app.test.ts | 166 ++++++++++++++++++++++++ src/lib/connect-app.ts | 243 ++++++++++++++++++++++++++++++++++++ src/lib/guide-content.ts | 3 + src/lib/types.ts | 13 ++ 13 files changed, 782 insertions(+), 4 deletions(-) create mode 100644 src/commands/connect.ts create mode 100644 src/lib/connect-app.test.ts create mode 100644 src/lib/connect-app.ts diff --git a/README.md b/README.md index 8ad5559..a38bc9e 100644 --- a/README.md +++ b/README.md @@ -527,6 +527,17 @@ What `one skills add connect` does: Commit `.agents/skills/one-connect` and the agent links so your team's agents share the skill. Agent mode (`--agent`) returns JSON for every subcommand. +### `one connect` + +Create and update a [One Connect](https://www.npmjs.com/package/@withone/connect) app from `one.connect.ts`, typed by `defineConnectApp` from `@withone/connect/config` (0.19.0 or later). + +```bash +one connect types # writes one-connect-env.d.ts: tool slugs and action names autocomplete +one connect create # creates the app, or updates it once .env holds its ONE_CLIENT_ID +``` + +`one connect create` uses your API key, writes `ONE_CLIENT_ID`, `ONE_PERMISSION_SET` and `ONE_REDIRECT_URI` to `.env.local` (or `.env`), and opens the app's **Credentials** page in the dashboard, which shows the client secret and the connect key once. The two secrets never pass through the terminal. Each tool in `ask` takes `access`: `read-only`, `read-write`, `full-access`, or `custom` with `actions`, named as `one connect types` wrote them or by id from `one actions search`. Options: `--config `, `--env-file `, `--no-open`. A `.ts` config needs Node 22.18 or later; on older Node use `one.connect.mjs`. + ### `one guide [topic]` Get the full CLI usage guide, designed for AI agents that only have the binary (no MCP, no IDE skills). diff --git a/package-lock.json b/package-lock.json index 8adb568..8cdddd5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@withone/cli", - "version": "2.9.2", + "version": "2.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@withone/cli", - "version": "2.9.2", + "version": "2.10.0", "dependencies": { "@clack/prompts": "^0.9.1", "commander": "^13.1.0", diff --git a/package.json b/package.json index 7528caf..fd58ab3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@withone/cli", - "version": "2.9.2", + "version": "2.10.0", "description": "CLI for managing One", "type": "module", "files": [ diff --git a/skills/one/SKILL.md b/skills/one/SKILL.md index 1a4fff4..3e92e63 100644 --- a/skills/one/SKILL.md +++ b/skills/one/SKILL.md @@ -373,6 +373,15 @@ one --agent skills add connect # from the @withone/connect version the app It lands in `.agents/skills/one-connect` (and `.claude/skills/one-connect`). The result's `next` array says what to do; the skill covers the button, the two routes, key mode vs token mode, and calling One with the grant. Never ask the user to paste the client secret or connect key into chat. +To create the Connect app itself without the dashboard, write `one.connect.ts` with `defineConnectApp` from `@withone/connect/config` (name, `redirectUris`, `ask` per tool with `access`: `read-only`, `read-write`, `full-access`, or `custom` with `actions`), then: + +```bash +one --agent connect types # tool slugs and action names autocomplete in one.connect.ts +one --agent connect create # creates or updates the app; writes ONE_CLIENT_ID, ONE_PERMISSION_SET, ONE_REDIRECT_URI to .env +``` + +It opens the app's Credentials page for the human: tell them to copy `ONE_CLIENT_SECRET` and `ONE_CONNECT_KEY` from there into the env file the result names (`pending`). The CLI never receives either secret. Run `connect create` again after editing the file to update the same app. + ## Adding New Connections If the user needs a platform that isn't connected yet, tell them to run: diff --git a/src/cli.ts b/src/cli.ts index d0ce3a2..879d885 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -43,6 +43,7 @@ import { relayEventTypesCommand, relayPlatformsCommand, } from './commands/relay.js'; +import { connectCreateCommand, connectTypesCommand } from './commands/connect.js'; import { registerSyncCommands } from './lib/memory/sync/index.js'; import { registerMemoryCommands } from './commands/mem.js'; @@ -95,6 +96,10 @@ program one skills update [connect] Re-copy from the installed @withone/connect after an upgrade one skills remove connect Remove it + Connect apps (one.connect.ts, typed by @withone/connect/config): + one connect create Create or update the app, write .env, open the page with its two secrets + one connect types Write one-connect-env.d.ts so tool slugs and action names autocomplete + Guide: one guide [topic] Full CLI guide (topics: overview, actions, workflows, memory, sync, all) @@ -658,6 +663,30 @@ flow await flowScaffoldCommand(template); }); +// ── Connect App Commands ── + +const connectApp = program + .command('connect') + .description('Create and update a One Connect app from one.connect.ts'); + +connectApp + .command('create') + .description('Create the app (or update it, once .env holds its ONE_CLIENT_ID), write .env, and open the page that shows its two secrets once') + .option('--config ', 'Config file (default: one.connect.ts, .mts, .mjs or .js in this directory)') + .option('--env-file ', 'Env file to write (default: .env.local if it exists, else .env)') + .option('--no-open', 'Print the secrets page instead of opening it') + .action(async (options: { config?: string; envFile?: string; open?: boolean }) => { + await connectCreateCommand(options); + }); + +connectApp + .command('types') + .description('Write one-connect-env.d.ts: every tool slug, and action names for the tools the config asks for') + .option('--config ', 'Config file (default: one.connect.ts, .mts, .mjs or .js in this directory)') + .action(async (options: { config?: string }) => { + await connectTypesCommand(options); + }); + // ── Relay Commands ── const relay = program diff --git a/src/commands/connect.ts b/src/commands/connect.ts new file mode 100644 index 0000000..b72375e --- /dev/null +++ b/src/commands/connect.ts @@ -0,0 +1,212 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import open from 'open'; +import pc from 'picocolors'; +import { ApiError, OneApi } from '../lib/api.js'; +import { getConnectCredentialsUrl } from '../lib/browser.js'; +import { ensureWhoAmI, getApiBase, getApiKey } from '../lib/config.js'; +import { + TYPES_FILE, + actionNames, + clientFields, + connectorRule, + defaultEnvFile, + findConfigFile, + loadConfigFile, + readEnvValue, + renderTypes, + resolveActions, + upsertEnv, + validateConfig, + type ConnectAppConfig, + type ConnectorRule, +} from '../lib/connect-app.js'; +import * as output from '../lib/output.js'; + +const ASK_NAME = 'Default permission set'; + +function requireApi(): OneApi { + const apiKey = getApiKey(); + if (!apiKey) output.error('Not configured. Run `one init` first.'); + return new OneApi(apiKey, getApiBase()); +} + +async function readConfig(cwd: string, explicit?: string): Promise<{ file: string; config: ConnectAppConfig }> { + const file = findConfigFile(cwd, explicit); + if (!file) { + output.error( + explicit + ? `No config at ${explicit}.` + : 'No one.connect.ts here. Create one with defineConnectApp from @withone/connect/config.' + ); + } + let loaded: unknown; + try { + loaded = await loadConfigFile(file); + } catch (error) { + output.error(`Couldn't load ${path.basename(file)}: ${error instanceof Error ? error.message : String(error)}`); + } + const checked = validateConfig(loaded); + if ('errors' in checked) output.error(`${path.basename(file)} has problems:\n ${checked.errors.join('\n ')}`); + return { file, config: checked.config }; +} + +function message(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** The ask as connector rules: slugs to catalog ids, action names to ids. */ +async function buildRules(api: OneApi, config: ConnectAppConfig): Promise { + const asked = Object.entries(config.ask ?? {}); + if (asked.length === 0) return []; + + const platforms = await api.listPlatforms(); + const idBySlug = new Map(platforms.map((p) => [p.platform, p.id])); + const unknownTools = asked.map(([slug]) => slug).filter((slug) => !idBySlug.has(slug)); + if (unknownTools.length > 0) { + output.error(`Not in One's catalog: ${unknownTools.join(', ')}. Run \`one platforms\` for the slugs.`); + } + + const rules: ConnectorRule[] = []; + for (const [slug, ask] of asked) { + const definitionId = idBySlug.get(slug)!; + if (ask.access !== 'custom') { + rules.push(connectorRule(definitionId, ask)); + continue; + } + const actions = await api.listAllAvailableActions(slug); + const resolved = resolveActions(ask.actions, actionNames(actions), new Set(actions.map((a) => a.systemId))); + if ('unknown' in resolved) { + output.error( + `${slug} has no action named ${resolved.unknown.join(', ')}. ` + + `Run \`one connect types\` for the names, or \`one actions search ${slug} ""\` for an id.` + ); + } + rules.push(connectorRule(definitionId, ask, resolved.ids)); + } + return rules; +} + +export async function connectCreateCommand(options: { config?: string; envFile?: string; open?: boolean }): Promise { + const cwd = process.cwd(); + const api = requireApi(); + const { file, config } = await readConfig(cwd, options.config); + const envFile = options.envFile ? path.resolve(cwd, options.envFile) : defaultEnvFile(cwd); + const envText = fs.existsSync(envFile) ? fs.readFileSync(envFile, 'utf8') : ''; + + const spinner = output.createSpinner(); + spinner.start(`Reading ${path.basename(file)}...`); + + try { + const rules = await buildRules(api, config); + const fields = clientFields(config); + const existingId = readEnvValue(envText, 'ONE_CLIENT_ID'); + + let app; + let created = false; + if (existingId) { + try { + app = await api.updateConnectApp(existingId, fields); + } catch (error) { + if (!(error instanceof ApiError && error.status === 404)) throw error; + } + } + if (!app) { + app = await api.createConnectApp(fields); + created = true; + } + + let permissionSetId = created ? undefined : readEnvValue(envText, 'ONE_PERMISSION_SET') || undefined; + if (rules.length > 0) { + const body = { name: ASK_NAME, connectorRules: rules }; + const set = permissionSetId + ? await api.updatePermissionSet(app.clientId, permissionSetId, body) + : await api.createPermissionSet(app.clientId, body); + permissionSetId = set.id; + } + + fs.writeFileSync( + envFile, + upsertEnv(envText, { + ONE_CLIENT_ID: app.clientId, + ONE_REDIRECT_URI: config.redirectUris[0], + ...(permissionSetId ? { ONE_PERMISSION_SET: permissionSetId } : {}), + ONE_CLIENT_SECRET: null, + ONE_CONNECT_KEY: null, + }) + ); + + const whoami = await ensureWhoAmI(api); + const credentialsUrl = getConnectCredentialsUrl(app.clientId, { + orgId: whoami?.organization?.id, + projectId: whoami?.project?.id, + }); + const secretsPending = !readEnvValue(envText, 'ONE_CLIENT_SECRET') || !readEnvValue(envText, 'ONE_CONNECT_KEY'); + const opening = options.open !== false && (created || secretsPending); + if (opening) await open(credentialsUrl).catch(() => undefined); + + const result = { + created, + clientId: app.clientId, + permissionSetId: permissionSetId ?? null, + redirectUris: config.redirectUris, + envFile: path.relative(cwd, envFile) || envFile, + credentialsUrl, + pending: secretsPending ? ['ONE_CLIENT_SECRET', 'ONE_CONNECT_KEY'] : [], + }; + + if (output.isAgentMode()) { + output.json(result); + return; + } + + spinner.stop(created ? `Created ${config.name}` : `Updated ${config.name}`); + console.log(); + console.log(` ${pc.dim('Client ID:')} ${app.clientId}`); + if (permissionSetId) console.log(` ${pc.dim('Permission set:')} ${permissionSetId}`); + console.log(` ${pc.dim('Wrote')} ${result.envFile}`); + console.log(); + if (secretsPending) { + console.log(` ${opening ? 'Opening your browser for the two secrets:' : 'Get the two secrets here:'}`); + console.log(` ${pc.underline(credentialsUrl)}`); + console.log(` ${pc.dim('Paste ONE_CLIENT_SECRET and ONE_CONNECT_KEY into')} ${result.envFile}${pc.dim('. They are never printed here.')}`); + console.log(); + } + } catch (error) { + spinner.stop('Failed'); + output.error(`Error: ${message(error)}`); + } +} + +export async function connectTypesCommand(options: { config?: string }): Promise { + const cwd = process.cwd(); + const api = requireApi(); + const spinner = output.createSpinner(); + spinner.start("Reading One's catalog..."); + + try { + const file = findConfigFile(cwd, options.config); + const config = file ? validateConfig(await loadConfigFile(file)) : null; + const tools = Object.keys(config && 'config' in config ? config.config.ask ?? {} : {}); + + const platforms = await api.listPlatforms(); + const known = new Set(platforms.map((p) => p.platform)); + const actionsByTool: Record = {}; + for (const tool of tools.filter((t) => known.has(t))) { + actionsByTool[tool] = [...actionNames(await api.listAllAvailableActions(tool)).keys()]; + } + + const target = path.join(file ? path.dirname(file) : cwd, TYPES_FILE); + fs.writeFileSync(target, renderTypes([...known], actionsByTool)); + + if (output.isAgentMode()) { + output.json({ file: target, tools: known.size, actions: actionsByTool }); + return; + } + spinner.stop(`Wrote ${path.relative(cwd, target) || target}`); + console.log(` ${pc.dim(`${known.size} tools; action names for ${Object.keys(actionsByTool).join(', ') || 'none yet (add tools to ask, then run it again)'}`)}`); + } catch (error) { + spinner.stop('Failed'); + output.error(`Error: ${message(error)}`); + } +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 35d3e4a..54edc75 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -19,6 +19,8 @@ import type { FindIntent, FoundActions, KeyAccessDocument, + ConnectAppView, + ConnectPermissionSetView, } from './types.js'; export class ApiError extends Error { @@ -593,6 +595,66 @@ export class OneApi { } } + // Connect app methods. A key creates and updates; the secret and the + // connect key are minted in the dashboard, never handed to the CLI. + + async createConnectApp(fields: Record): Promise { + return this.requestFull({ + path: '/oauth-clients', + method: 'POST', + body: { ...fields, clientType: 'confidential', consentSurface: 'connect' }, + }); + } + + async getConnectApp(clientId: string): Promise { + return this.request(`/oauth-clients/${encodeURIComponent(clientId)}`); + } + + async updateConnectApp(clientId: string, fields: Record): Promise { + return this.requestFull({ + path: `/oauth-clients/${encodeURIComponent(clientId)}`, + method: 'PATCH', + body: fields, + }); + } + + async createPermissionSet(clientId: string, body: { name: string; connectorRules: unknown[] }): Promise { + return this.requestFull({ + path: `/oauth-clients/${encodeURIComponent(clientId)}/permission-sets`, + method: 'POST', + body, + }); + } + + async updatePermissionSet( + clientId: string, + setId: string, + body: { name: string; connectorRules: unknown[] } + ): Promise { + return this.requestFull({ + path: `/oauth-clients/${encodeURIComponent(clientId)}/permission-sets/${encodeURIComponent(setId)}`, + method: 'PATCH', + body, + }); + } + + /** Every action on a platform, page by page, following the server's `pages`. */ + async listAllAvailableActions(platform: string): Promise { + const all: AvailableAction[] = []; + let page = 1; + let totalPages = 1; + do { + const response = await this.requestFull<{ rows?: AvailableAction[]; pages?: number }>({ + path: `/available-actions/${encodeURIComponent(platform)}`, + queryParams: { page: String(page), limit: '100' }, + }); + all.push(...(response.rows || [])); + totalPages = response.pages || 1; + page++; + } while (page <= totalPages); + return all; + } + // Webhook Relay methods async createRelayEndpoint(body: { diff --git a/src/lib/browser.test.ts b/src/lib/browser.test.ts index c54c293..f68ca9e 100644 --- a/src/lib/browser.test.ts +++ b/src/lib/browser.test.ts @@ -1,6 +1,6 @@ import { describe, it, beforeEach, afterEach } from 'node:test'; import assert from 'node:assert/strict'; -import { getApiKeyUrl, getCliAuthUrl, getConnectionUrl, oneAppUrl } from './browser.js'; +import { getApiKeyUrl, getCliAuthUrl, getConnectCredentialsUrl, getConnectionUrl, oneAppUrl } from './browser.js'; describe('oneAppUrl', () => { let saved: string | undefined; @@ -58,3 +58,20 @@ describe('getCliAuthUrl', () => { assert.equal(url.searchParams.get('user'), null); }); }); + +describe('getConnectCredentialsUrl', () => { + let saved: string | undefined; + beforeEach(() => { saved = process.env.ONE_APP_URL; delete process.env.ONE_APP_URL; }); + afterEach(() => { + if (saved === undefined) delete process.env.ONE_APP_URL; + else process.env.ONE_APP_URL = saved; + }); + + it('opens the app’s credentials page in the space it was created in', () => { + assert.equal( + getConnectCredentialsUrl('f305b9fc', { orgId: 'org-1', projectId: 'proj-1' }), + 'https://app.withone.ai/developers/connect/f305b9fc/credentials?orgId=org-1&projectId=proj-1' + ); + assert.equal(getConnectCredentialsUrl('f305b9fc'), 'https://app.withone.ai/developers/connect/f305b9fc/credentials'); + }); +}); diff --git a/src/lib/browser.ts b/src/lib/browser.ts index 5cbd941..a4c2741 100644 --- a/src/lib/browser.ts +++ b/src/lib/browser.ts @@ -33,6 +33,19 @@ export function getApiKeyUrl(): string { return `${oneAppUrl()}/settings/api-keys`; } +/** + * The page that mints a Connect app's client secret and connect key and + * shows them once. The org and project name the space the app was created + * in, so the page reads it from there whatever the header has selected. + */ +export function getConnectCredentialsUrl(clientId: string, params?: Pick): string { + const searchParams = new URLSearchParams(); + if (params?.orgId) searchParams.set('orgId', params.orgId); + if (params?.projectId) searchParams.set('projectId', params.projectId); + const qs = searchParams.toString(); + return `${oneAppUrl()}/developers/connect/${encodeURIComponent(clientId)}/credentials${qs ? `?${qs}` : ''}`; +} + export async function openConnectionPage(platform: string, params?: ConnectionUrlParams): Promise { await open(getConnectionUrl(platform, params)); } diff --git a/src/lib/connect-app.test.ts b/src/lib/connect-app.test.ts new file mode 100644 index 0000000..af357d5 --- /dev/null +++ b/src/lib/connect-app.test.ts @@ -0,0 +1,166 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + actionName, + actionNames, + clientFields, + connectorRule, + findConfigFile, + loadConfigFile, + readEnvValue, + renderTypes, + resolveActions, + upsertEnv, + validateConfig, +} from './connect-app.js'; + +describe('actionName', () => { + it('camel-cases a title', () => { + assert.equal(actionName('Send Email'), 'sendEmail'); + assert.equal(actionName('List and View Email Threads'), 'listAndViewEmailThreads'); + assert.equal(actionName('GET a contact (v2)'), 'getAContactV2'); + }); +}); + +describe('actionNames', () => { + it('leaves out a name two actions share, so a name never picks one silently', () => { + const names = actionNames([ + { systemId: 'conn_mod_def::a', title: 'Send Email' }, + { systemId: 'conn_mod_def::b', title: 'List Labels' }, + { systemId: 'conn_mod_def::c', title: 'list labels' }, + ]); + assert.deepEqual([...names.entries()], [['sendEmail', 'conn_mod_def::a']]); + }); +}); + +describe('resolveActions', () => { + const names = new Map([['sendEmail', 'conn_mod_def::a']]); + const ids = new Set(['conn_mod_def::a', 'conn_mod_def::b']); + + it('turns names and known ids into ids, once each', () => { + assert.deepEqual(resolveActions(['sendEmail', 'conn_mod_def::b', 'conn_mod_def::a'], names, ids), { + ids: ['conn_mod_def::a', 'conn_mod_def::b'], + }); + }); + + it('names everything that did not match', () => { + assert.deepEqual(resolveActions(['sendEmails', 'conn_mod_def::zzz'], names, ids), { + unknown: ['sendEmails', 'conn_mod_def::zzz'], + }); + }); +}); + +describe('connectorRule', () => { + it('maps each level to the methods the dashboard writes', () => { + assert.deepEqual(connectorRule(7, { access: 'read-only' }).methods, ['GET']); + assert.deepEqual(connectorRule(7, { access: 'read-write' }).methods, ['GET', 'POST', 'PUT', 'PATCH']); + assert.deepEqual(connectorRule(7, { access: 'full-access' }), { + type: 'connectionDefinition', + connectionDefinitionId: 7, + methods: null, + actionIds: null, + }); + }); + + it('narrows a custom ask to its actions', () => { + assert.deepEqual(connectorRule(7, { access: 'custom', actions: ['sendEmail'] }, ['conn_mod_def::a']), { + type: 'connectionDefinition', + connectionDefinitionId: 7, + methods: null, + actionIds: ['conn_mod_def::a'], + }); + }); +}); + +describe('clientFields', () => { + it('sends only what the file sets, branding as the frontend spec', () => { + assert.deepEqual( + clientFields({ + name: ' Mastra ', + redirectUris: ['http://localhost:3000/api/one/callback'], + reason: 'Mastra plans your day', + branding: { panelColor: '#A02235', buttonColor: '#6A2557' }, + }), + { + name: 'Mastra', + redirectUris: ['http://localhost:3000/api/one/callback'], + consentReason: 'Mastra plans your day', + frontend: { panelColor: '#A02235', buttonColor: '#6A2557', cobrandEmail: false }, + } + ); + }); +}); + +describe('validateConfig', () => { + it('accepts a complete config', () => { + const result = validateConfig({ + name: 'Mastra', + redirectUris: ['https://app.mastra.ai/api/one/callback'], + ask: { gmail: { access: 'custom', actions: ['sendEmail'] }, linear: { access: 'read-write' } }, + }); + assert.ok('config' in result); + }); + + it('lists every problem at once', () => { + const result = validateConfig({ + name: '', + redirectUris: ['localhost:3000/callback'], + branding: { panelColor: 'red', buttonColor: '#6A2557' }, + ask: { gmail: { access: 'custom', actions: [] }, linear: { access: 'write' } }, + }); + assert.ok('errors' in result); + assert.equal(result.errors.length, 5); + }); +}); + +describe('upsertEnv', () => { + it('replaces in place, keeps other lines, and appends what is missing', () => { + const before = '# app\nDATABASE_URL=postgres://x\nONE_CLIENT_ID=old\n'; + assert.equal( + upsertEnv(before, { ONE_CLIENT_ID: 'new', ONE_REDIRECT_URI: 'http://localhost:3000/api/one/callback' }), + '# app\nDATABASE_URL=postgres://x\nONE_CLIENT_ID=new\nONE_REDIRECT_URI=http://localhost:3000/api/one/callback\n' + ); + }); + + it('never clears a secret already pasted in', () => { + const before = 'ONE_CLIENT_SECRET=one_secret_kept'; + assert.equal( + upsertEnv(before, { ONE_CLIENT_SECRET: null, ONE_CONNECT_KEY: null }), + 'ONE_CLIENT_SECRET=one_secret_kept\nONE_CONNECT_KEY=\n' + ); + assert.equal(readEnvValue('ONE_CLIENT_ID="abc"\n', 'ONE_CLIENT_ID'), 'abc'); + }); +}); + +describe('config files', () => { + it('finds and loads an .mjs config', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'one-connect-')); + try { + fs.writeFileSync( + path.join(dir, 'one.connect.mjs'), + 'export default { name: "Mastra", redirectUris: ["http://localhost:3000/api/one/callback"] };\n' + ); + const file = findConfigFile(dir); + assert.equal(file, path.join(dir, 'one.connect.mjs')); + assert.deepEqual(await loadConfigFile(file!), { + name: 'Mastra', + redirectUris: ['http://localhost:3000/api/one/callback'], + }); + assert.equal(findConfigFile(dir, 'missing.ts'), null); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe('renderTypes', () => { + it('augments the registry with every tool and the asked tools’ action names', () => { + const text = renderTypes(['linear', 'gmail'], { gmail: ['sendEmail', 'createDraft'] }); + assert.match(text, /declare module "@withone\/connect\/config"/); + assert.match(text, /tools: "gmail" \| "linear";/); + assert.match(text, /"gmail": "createDraft" \| "sendEmail";/); + }); +}); diff --git a/src/lib/connect-app.ts b/src/lib/connect-app.ts new file mode 100644 index 0000000..5829c4c --- /dev/null +++ b/src/lib/connect-app.ts @@ -0,0 +1,243 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +/** + * `one connect create` and `one connect types`: a Connect app described in + * `one.connect.ts` (typed by `defineConnectApp` from `@withone/connect/config`) + * becomes an app on One. Everything here is pure or file-local; the command + * does the network calls. + */ + +export const CONFIG_FILES = ['one.connect.ts', 'one.connect.mts', 'one.connect.mjs', 'one.connect.js'] as const; + +/** Written beside the config so the editor picks it up like `next-env.d.ts`. */ +export const TYPES_FILE = 'one-connect-env.d.ts'; + +export type ConnectAccess = 'read-only' | 'read-write' | 'full-access'; + +export type ToolAsk = { access: ConnectAccess } | { access: 'custom'; actions: string[] }; + +export interface ConnectAppConfig { + name: string; + redirectUris: string[]; + description?: string; + logoUrl?: string; + homepageUrl?: string; + reason?: string; + branding?: { panelColor: string; buttonColor: string; cobrandEmail?: boolean }; + ask?: Record; +} + +/** The dashboard's mapping from a level to the methods a rule admits + * (core-ui `policyToRuleFields`); `null` admits every method. */ +const ACCESS_METHODS: Record = { + 'read-only': ['GET'], + 'read-write': ['GET', 'POST', 'PUT', 'PATCH'], + 'full-access': null, +}; + +const HEX_COLOR = /^#[0-9a-fA-F]{6}$/; +const ACTION_ID_PREFIX = 'conn_mod_def::'; + +export function findConfigFile(cwd: string, explicit?: string): string | null { + if (explicit) { + const resolved = path.resolve(cwd, explicit); + return fs.existsSync(resolved) ? resolved : null; + } + for (const name of CONFIG_FILES) { + const candidate = path.join(cwd, name); + if (fs.existsSync(candidate)) return candidate; + } + return null; +} + +/** + * Imports the config's default export. A `.ts` file loads natively on + * Node 22.18+ (type stripping); older Node gets a message pointing at the + * `.mjs` form instead of a stack trace. + */ +export async function loadConfigFile(file: string): Promise { + try { + const mod = await import(pathToFileURL(file).href); + return mod.default ?? mod; + } catch (error) { + const code = (error as { code?: string }).code; + if (code === 'ERR_UNKNOWN_FILE_EXTENSION' && /\.m?ts$/.test(file)) { + throw new Error( + `Node ${process.versions.node} can't load ${path.basename(file)}. Use Node 22.18 or later, ` + + `or rename it to one.connect.mjs and type it with ` + + `/** @type {import("@withone/connect/config").ConnectAppConfig} */.` + ); + } + throw error; + } +} + +/** Checks the shape the API would refuse, so the developer hears it before + * anything is created. Returns the config typed, or every problem found. */ +export function validateConfig(value: unknown): { config: ConnectAppConfig } | { errors: string[] } { + const errors: string[] = []; + const v = value as Partial | null; + if (!v || typeof v !== 'object') return { errors: ['The config must export an object (use defineConnectApp).'] }; + + if (typeof v.name !== 'string' || !v.name.trim()) errors.push('`name` is required.'); + if (!Array.isArray(v.redirectUris) || v.redirectUris.length === 0) { + errors.push('`redirectUris` needs at least one callback URL.'); + } else { + for (const uri of v.redirectUris) { + if (typeof uri !== 'string' || !/^https?:\/\//.test(uri)) errors.push(`Redirect URI "${uri}" must be an http(s) URL.`); + } + } + if (v.reason !== undefined && (typeof v.reason !== 'string' || v.reason.trim().length === 0 || v.reason.length > 200)) { + errors.push('`reason` must be one sentence of at most 200 characters.'); + } + if (v.branding) { + for (const key of ['panelColor', 'buttonColor'] as const) { + if (!HEX_COLOR.test(String(v.branding[key] ?? ''))) errors.push(`\`branding.${key}\` must be a #RRGGBB colour.`); + } + } + for (const [tool, ask] of Object.entries(v.ask ?? {})) { + const access = (ask as { access?: unknown })?.access; + if (access === 'custom') { + const actions = (ask as { actions?: unknown }).actions; + if (!Array.isArray(actions) || actions.length === 0) errors.push(`\`ask.${tool}\` is custom, so it needs at least one action.`); + } else if (typeof access !== 'string' || !(access in ACCESS_METHODS)) { + errors.push(`\`ask.${tool}.access\` must be read-only, read-write, full-access or custom.`); + } + } + + return errors.length > 0 ? { errors } : { config: v as ConnectAppConfig }; +} + +/** + * The name an action goes by in the config: its title in camel case. + * "List and View Email Threads" → "listAndViewEmailThreads". + */ +export function actionName(title: string): string { + const words = title.split(/[^A-Za-z0-9]+/).filter(Boolean); + return words + .map((word, i) => (i === 0 ? word.toLowerCase() : word[0].toUpperCase() + word.slice(1).toLowerCase())) + .join(''); +} + +/** + * Names for a tool's actions, keyed by name. A name two actions share is + * left out: those actions are reachable by id only, so a name never picks + * one of them silently. + */ +export function actionNames(actions: Array<{ systemId: string; title: string }>): Map { + const byName = new Map(); + const shared = new Set(); + for (const action of actions) { + const name = actionName(action.title); + if (!name) continue; + if (byName.has(name)) shared.add(name); + else byName.set(name, action.systemId); + } + for (const name of shared) byName.delete(name); + return byName; +} + +/** Turns a custom ask's names and ids into ids, or names what didn't match. */ +export function resolveActions( + refs: string[], + names: Map, + ids: Set +): { ids: string[] } | { unknown: string[] } { + const resolved: string[] = []; + const unknown: string[] = []; + for (const ref of refs) { + const id = ref.startsWith(ACTION_ID_PREFIX) ? (ids.has(ref) ? ref : undefined) : names.get(ref); + if (id) resolved.push(id); + else unknown.push(ref); + } + return unknown.length > 0 ? { unknown } : { ids: [...new Set(resolved)] }; +} + +export interface ConnectorRule { + type: 'connectionDefinition'; + connectionDefinitionId: number; + methods: string[] | null; + actionIds: string[] | null; +} + +/** One tool's rule, shaped as the dashboard writes it. */ +export function connectorRule(connectionDefinitionId: number, ask: ToolAsk, actionIds: string[] = []): ConnectorRule { + if (ask.access === 'custom') return { type: 'connectionDefinition', connectionDefinitionId, methods: null, actionIds }; + return { type: 'connectionDefinition', connectionDefinitionId, methods: ACCESS_METHODS[ask.access], actionIds: null }; +} + +/** The app's fields as the client API takes them. Only what the file sets is + * sent, so an update leaves everything else as it is. */ +export function clientFields(config: ConnectAppConfig): Record { + const fields: Record = { name: config.name.trim(), redirectUris: config.redirectUris }; + if (config.description) fields.description = config.description; + if (config.logoUrl) fields.logoUrl = config.logoUrl; + if (config.homepageUrl) fields.homepageUrl = config.homepageUrl; + if (config.reason) fields.consentReason = config.reason.trim(); + if (config.branding) { + fields.frontend = { + panelColor: config.branding.panelColor, + buttonColor: config.branding.buttonColor, + cobrandEmail: config.branding.cobrandEmail ?? false, + }; + } + return fields; +} + +/** `.env.local` when the project has one (Next.js), else `.env`. */ +export function defaultEnvFile(cwd: string): string { + const local = path.join(cwd, '.env.local'); + return fs.existsSync(local) ? local : path.join(cwd, '.env'); +} + +export function readEnvValue(text: string, key: string): string | undefined { + const match = text.match(new RegExp(`^${key}=(.*)$`, 'm')); + return match ? match[1].trim().replace(/^["']|["']$/g, '') : undefined; +} + +/** + * Sets each key in place, keeping every other line and comment. A key that + * is missing is appended; `null` appends it empty only when it is missing, so + * a secret already pasted in is never cleared. + */ +export function upsertEnv(text: string, values: Record): string { + let next = text; + const appended: string[] = []; + for (const [key, value] of Object.entries(values)) { + const line = new RegExp(`^${key}=.*$`, 'm'); + if (line.test(next)) { + if (value !== null) next = next.replace(line, `${key}=${value}`); + } else { + appended.push(`${key}=${value ?? ''}`); + } + } + if (appended.length === 0) return next; + const separator = next.length === 0 || next.endsWith('\n') ? '' : '\n'; + return `${next}${separator}${appended.join('\n')}\n`; +} + +/** The declaration file `one connect types` writes. */ +export function renderTypes(tools: string[], actionsByTool: Record): string { + const literal = (value: string) => JSON.stringify(value); + const union = (values: string[]) => (values.length > 0 ? values.map(literal).join(' | ') : 'never'); + const actionLines = Object.entries(actionsByTool) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([tool, names]) => ` ${literal(tool)}: ${union([...names].sort())};`); + + return [ + '// Generated by `one connect types`. Run it again after changing the tools in one.connect.ts.', + 'import "@withone/connect/config";', + '', + 'declare module "@withone/connect/config" {', + ' interface ConnectRegistry {', + ` tools: ${union([...tools].sort())};`, + ' actions: {', + ...actionLines, + ' };', + ' }', + '}', + '', + ].join('\n'); +} diff --git a/src/lib/guide-content.ts b/src/lib/guide-content.ts index 8321784..ca88237 100644 --- a/src/lib/guide-content.ts +++ b/src/lib/guide-content.ts @@ -113,6 +113,9 @@ one --agent relay deliveries --endpoint-id # Check delivery - \`--create-webhook\` auto-registers the webhook URL with the source platform - Use \`actions find\` (and \`actions load\` for more) to learn both the incoming payload shape AND the destination API shape before building templates +### One Connect apps — created from \`one.connect.ts\` +\`one connect create\` creates (or, once .env holds its \`ONE_CLIENT_ID\`, updates) a One Connect app from \`one.connect.ts\`, typed by \`defineConnectApp\` from \`@withone/connect/config\`. It writes \`ONE_CLIENT_ID\`, \`ONE_PERMISSION_SET\` and \`ONE_REDIRECT_URI\` to the env file and opens the app's Credentials page, where the human copies the client secret and connect key; the CLI never receives them. \`one connect types\` writes \`one-connect-env.d.ts\` so tool slugs and action names autocomplete. Each tool's \`access\` is \`read-only\`, \`read-write\`, \`full-access\`, or \`custom\` with \`actions\` (names, or ids from \`actions find\`). + ### 4. Memory + Sync — Unified store with hybrid FTS + semantic search One ships a local memory store (a real Postgres process bootstrapped on demand via the bundled \`embedded-postgres\` plugin, with a \`postgres\` plugin for remote/self-hosted) that backs both user-authored notes (\`one mem add\`) and synced platform data (\`one sync run\`). Auto-initializes on first use — no separate install step. Run \`one guide memory\` and \`one guide sync\` for the full references. diff --git a/src/lib/types.ts b/src/lib/types.ts index 037fb02..c5d38a7 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -186,6 +186,19 @@ export interface AvailableAction { method: string; } +/** A Connect app as `/v1/oauth-clients` answers an API key: never a secret. */ +export interface ConnectAppView { + id: string; + clientId: string; + name: string; + redirectUris: string[]; +} + +export interface ConnectPermissionSetView { + id: string; + name: string; +} + export interface IoSchemaGroup { required?: string[]; properties?: Record; From 2e6c0c93af5730f134b3b7ebe6cdda243ad1fdf7 Mon Sep 17 00:00:00 2001 From: Krish Parekh Date: Wed, 28 Oct 2026 19:25:06 +0530 Subject: [PATCH 2/9] fix(connect): write the client secret the API returns straight to .env; the page makes the connect key Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- skills/one/SKILL.md | 4 ++-- src/cli.ts | 4 ++-- src/commands/connect.ts | 33 +++++++++++++++++---------------- src/lib/api.ts | 4 ++-- src/lib/connect-app.ts | 23 ++++------------------- src/lib/guide-content.ts | 2 +- src/lib/types.ts | 4 +++- 8 files changed, 32 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index a38bc9e..01defe6 100644 --- a/README.md +++ b/README.md @@ -536,7 +536,7 @@ one connect types # writes one-connect-env.d.ts: tool slugs and action names one connect create # creates the app, or updates it once .env holds its ONE_CLIENT_ID ``` -`one connect create` uses your API key, writes `ONE_CLIENT_ID`, `ONE_PERMISSION_SET` and `ONE_REDIRECT_URI` to `.env.local` (or `.env`), and opens the app's **Credentials** page in the dashboard, which shows the client secret and the connect key once. The two secrets never pass through the terminal. Each tool in `ask` takes `access`: `read-only`, `read-write`, `full-access`, or `custom` with `actions`, named as `one connect types` wrote them or by id from `one actions search`. Options: `--config `, `--env-file `, `--no-open`. A `.ts` config needs Node 22.18 or later; on older Node use `one.connect.mjs`. +`one connect create` uses your API key and writes `ONE_CLIENT_ID`, `ONE_CLIENT_SECRET`, `ONE_PERMISSION_SET` and `ONE_REDIRECT_URI` to `.env.local` (or `.env`); the secret goes straight into the file and is never printed. It then opens the app's **Credentials** page in the dashboard, which makes the connect key and shows it once. Each tool in `ask` takes `access`: `read-only`, `read-write`, `full-access`, or `custom` with `actions`, named as `one connect types` wrote them or by id from `one actions search`. Options: `--config `, `--env-file `, `--no-open`. ### `one guide [topic]` diff --git a/skills/one/SKILL.md b/skills/one/SKILL.md index 3e92e63..31e1549 100644 --- a/skills/one/SKILL.md +++ b/skills/one/SKILL.md @@ -377,10 +377,10 @@ To create the Connect app itself without the dashboard, write `one.connect.ts` w ```bash one --agent connect types # tool slugs and action names autocomplete in one.connect.ts -one --agent connect create # creates or updates the app; writes ONE_CLIENT_ID, ONE_PERMISSION_SET, ONE_REDIRECT_URI to .env +one --agent connect create # creates or updates the app; writes ONE_CLIENT_ID, ONE_CLIENT_SECRET, ONE_PERMISSION_SET, ONE_REDIRECT_URI to .env ``` -It opens the app's Credentials page for the human: tell them to copy `ONE_CLIENT_SECRET` and `ONE_CONNECT_KEY` from there into the env file the result names (`pending`). The CLI never receives either secret. Run `connect create` again after editing the file to update the same app. +The client secret goes straight into the env file and never appears in the output; do not read it back. It opens the app's Credentials page for the human: tell them to copy each key in `pending` (usually `ONE_CONNECT_KEY`) from there into the env file the result names. Run `connect create` again after editing the file to update the same app. ## Adding New Connections diff --git a/src/cli.ts b/src/cli.ts index 879d885..52adb7a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -97,7 +97,7 @@ program one skills remove connect Remove it Connect apps (one.connect.ts, typed by @withone/connect/config): - one connect create Create or update the app, write .env, open the page with its two secrets + one connect create Create or update the app, write .env, open the page for its connect key one connect types Write one-connect-env.d.ts so tool slugs and action names autocomplete Guide: @@ -671,7 +671,7 @@ const connectApp = program connectApp .command('create') - .description('Create the app (or update it, once .env holds its ONE_CLIENT_ID), write .env, and open the page that shows its two secrets once') + .description('Create the app (or update it, once .env holds its ONE_CLIENT_ID), write .env (the client secret is written, never printed), and open the page that makes its connect key') .option('--config ', 'Config file (default: one.connect.ts, .mts, .mjs or .js in this directory)') .option('--env-file ', 'Env file to write (default: .env.local if it exists, else .env)') .option('--no-open', 'Print the secrets page instead of opening it') diff --git a/src/commands/connect.ts b/src/commands/connect.ts index b72375e..661085d 100644 --- a/src/commands/connect.ts +++ b/src/commands/connect.ts @@ -125,24 +125,24 @@ export async function connectCreateCommand(options: { config?: string; envFile?: permissionSetId = set.id; } - fs.writeFileSync( - envFile, - upsertEnv(envText, { - ONE_CLIENT_ID: app.clientId, - ONE_REDIRECT_URI: config.redirectUris[0], - ...(permissionSetId ? { ONE_PERMISSION_SET: permissionSetId } : {}), - ONE_CLIENT_SECRET: null, - ONE_CONNECT_KEY: null, - }) - ); + // The secret comes back once, on creation: it goes straight into the env + // file and is never printed or returned. + const nextEnv = upsertEnv(envText, { + ONE_CLIENT_ID: app.clientId, + ONE_REDIRECT_URI: config.redirectUris[0], + ...(permissionSetId ? { ONE_PERMISSION_SET: permissionSetId } : {}), + ONE_CLIENT_SECRET: app.clientSecret ?? null, + ONE_CONNECT_KEY: null, + }); + fs.writeFileSync(envFile, nextEnv); const whoami = await ensureWhoAmI(api); const credentialsUrl = getConnectCredentialsUrl(app.clientId, { orgId: whoami?.organization?.id, projectId: whoami?.project?.id, }); - const secretsPending = !readEnvValue(envText, 'ONE_CLIENT_SECRET') || !readEnvValue(envText, 'ONE_CONNECT_KEY'); - const opening = options.open !== false && (created || secretsPending); + const pending = (['ONE_CLIENT_SECRET', 'ONE_CONNECT_KEY'] as const).filter((key) => !readEnvValue(nextEnv, key)); + const opening = options.open !== false && pending.length > 0; if (opening) await open(credentialsUrl).catch(() => undefined); const result = { @@ -152,7 +152,7 @@ export async function connectCreateCommand(options: { config?: string; envFile?: redirectUris: config.redirectUris, envFile: path.relative(cwd, envFile) || envFile, credentialsUrl, - pending: secretsPending ? ['ONE_CLIENT_SECRET', 'ONE_CONNECT_KEY'] : [], + pending, }; if (output.isAgentMode()) { @@ -166,10 +166,11 @@ export async function connectCreateCommand(options: { config?: string; envFile?: if (permissionSetId) console.log(` ${pc.dim('Permission set:')} ${permissionSetId}`); console.log(` ${pc.dim('Wrote')} ${result.envFile}`); console.log(); - if (secretsPending) { - console.log(` ${opening ? 'Opening your browser for the two secrets:' : 'Get the two secrets here:'}`); + if (pending.length > 0) { + const what = pending.includes('ONE_CLIENT_SECRET') ? 'the connect key and a new client secret' : 'the connect key'; + console.log(` ${opening ? `Opening your browser for ${what}:` : `Get ${what} here:`}`); console.log(` ${pc.underline(credentialsUrl)}`); - console.log(` ${pc.dim('Paste ONE_CLIENT_SECRET and ONE_CONNECT_KEY into')} ${result.envFile}${pc.dim('. They are never printed here.')}`); + console.log(` ${pc.dim(`Paste ${pending.join(' and ')} into`)} ${result.envFile}${pc.dim('. Secrets are never printed here.')}`); console.log(); } } catch (error) { diff --git a/src/lib/api.ts b/src/lib/api.ts index 54edc75..371c222 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -595,8 +595,8 @@ export class OneApi { } } - // Connect app methods. A key creates and updates; the secret and the - // connect key are minted in the dashboard, never handed to the CLI. + // Connect app methods. A key creates and updates; the client secret comes + // back once on creation, and the connect key is minted in the dashboard. async createConnectApp(fields: Record): Promise { return this.requestFull({ diff --git a/src/lib/connect-app.ts b/src/lib/connect-app.ts index 5829c4c..ace4aae 100644 --- a/src/lib/connect-app.ts +++ b/src/lib/connect-app.ts @@ -52,26 +52,11 @@ export function findConfigFile(cwd: string, explicit?: string): string | null { return null; } -/** - * Imports the config's default export. A `.ts` file loads natively on - * Node 22.18+ (type stripping); older Node gets a message pointing at the - * `.mjs` form instead of a stack trace. - */ +/** Imports the config's default export. Every Node the CLI runs on strips + * types, so a `.ts` file loads as it is. */ export async function loadConfigFile(file: string): Promise { - try { - const mod = await import(pathToFileURL(file).href); - return mod.default ?? mod; - } catch (error) { - const code = (error as { code?: string }).code; - if (code === 'ERR_UNKNOWN_FILE_EXTENSION' && /\.m?ts$/.test(file)) { - throw new Error( - `Node ${process.versions.node} can't load ${path.basename(file)}. Use Node 22.18 or later, ` + - `or rename it to one.connect.mjs and type it with ` + - `/** @type {import("@withone/connect/config").ConnectAppConfig} */.` - ); - } - throw error; - } + const mod = await import(pathToFileURL(file).href); + return mod.default ?? mod; } /** Checks the shape the API would refuse, so the developer hears it before diff --git a/src/lib/guide-content.ts b/src/lib/guide-content.ts index ca88237..e6dd4b4 100644 --- a/src/lib/guide-content.ts +++ b/src/lib/guide-content.ts @@ -114,7 +114,7 @@ one --agent relay deliveries --endpoint-id # Check delivery - Use \`actions find\` (and \`actions load\` for more) to learn both the incoming payload shape AND the destination API shape before building templates ### One Connect apps — created from \`one.connect.ts\` -\`one connect create\` creates (or, once .env holds its \`ONE_CLIENT_ID\`, updates) a One Connect app from \`one.connect.ts\`, typed by \`defineConnectApp\` from \`@withone/connect/config\`. It writes \`ONE_CLIENT_ID\`, \`ONE_PERMISSION_SET\` and \`ONE_REDIRECT_URI\` to the env file and opens the app's Credentials page, where the human copies the client secret and connect key; the CLI never receives them. \`one connect types\` writes \`one-connect-env.d.ts\` so tool slugs and action names autocomplete. Each tool's \`access\` is \`read-only\`, \`read-write\`, \`full-access\`, or \`custom\` with \`actions\` (names, or ids from \`actions find\`). +\`one connect create\` creates (or, once .env holds its \`ONE_CLIENT_ID\`, updates) a One Connect app from \`one.connect.ts\`, typed by \`defineConnectApp\` from \`@withone/connect/config\`. It writes \`ONE_CLIENT_ID\`, \`ONE_CLIENT_SECRET\` (never printed), \`ONE_PERMISSION_SET\` and \`ONE_REDIRECT_URI\` to the env file and opens the app's Credentials page, where the human copies the connect key. \`one connect types\` writes \`one-connect-env.d.ts\` so tool slugs and action names autocomplete. Each tool's \`access\` is \`read-only\`, \`read-write\`, \`full-access\`, or \`custom\` with \`actions\` (names, or ids from \`actions find\`). ### 4. Memory + Sync — Unified store with hybrid FTS + semantic search One ships a local memory store (a real Postgres process bootstrapped on demand via the bundled \`embedded-postgres\` plugin, with a \`postgres\` plugin for remote/self-hosted) that backs both user-authored notes (\`one mem add\`) and synced platform data (\`one sync run\`). Auto-initializes on first use — no separate install step. Run \`one guide memory\` and \`one guide sync\` for the full references. diff --git a/src/lib/types.ts b/src/lib/types.ts index c5d38a7..509b06d 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -186,12 +186,14 @@ export interface AvailableAction { method: string; } -/** A Connect app as `/v1/oauth-clients` answers an API key: never a secret. */ +/** A Connect app as `/v1/oauth-clients` answers. `clientSecret` is there + * once, on creation; the CLI writes it to the env file and never prints it. */ export interface ConnectAppView { id: string; clientId: string; name: string; redirectUris: string[]; + clientSecret?: string | null; } export interface ConnectPermissionSetView { From 240226d83feef5b5a3e139e8ada032055ba7a0c5 Mon Sep 17 00:00:00 2001 From: Krish Parekh Date: Wed, 28 Oct 2026 19:44:36 +0530 Subject: [PATCH 3/9] Revert "fix(connect): write the client secret the API returns straight to .env; the page makes the connect key" This reverts commit 2e6c0c93af5730f134b3b7ebe6cdda243ad1fdf7. --- README.md | 2 +- skills/one/SKILL.md | 4 ++-- src/cli.ts | 4 ++-- src/commands/connect.ts | 33 ++++++++++++++++----------------- src/lib/api.ts | 4 ++-- src/lib/connect-app.ts | 23 +++++++++++++++++++---- src/lib/guide-content.ts | 2 +- src/lib/types.ts | 4 +--- 8 files changed, 44 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 01defe6..a38bc9e 100644 --- a/README.md +++ b/README.md @@ -536,7 +536,7 @@ one connect types # writes one-connect-env.d.ts: tool slugs and action names one connect create # creates the app, or updates it once .env holds its ONE_CLIENT_ID ``` -`one connect create` uses your API key and writes `ONE_CLIENT_ID`, `ONE_CLIENT_SECRET`, `ONE_PERMISSION_SET` and `ONE_REDIRECT_URI` to `.env.local` (or `.env`); the secret goes straight into the file and is never printed. It then opens the app's **Credentials** page in the dashboard, which makes the connect key and shows it once. Each tool in `ask` takes `access`: `read-only`, `read-write`, `full-access`, or `custom` with `actions`, named as `one connect types` wrote them or by id from `one actions search`. Options: `--config `, `--env-file `, `--no-open`. +`one connect create` uses your API key, writes `ONE_CLIENT_ID`, `ONE_PERMISSION_SET` and `ONE_REDIRECT_URI` to `.env.local` (or `.env`), and opens the app's **Credentials** page in the dashboard, which shows the client secret and the connect key once. The two secrets never pass through the terminal. Each tool in `ask` takes `access`: `read-only`, `read-write`, `full-access`, or `custom` with `actions`, named as `one connect types` wrote them or by id from `one actions search`. Options: `--config `, `--env-file `, `--no-open`. A `.ts` config needs Node 22.18 or later; on older Node use `one.connect.mjs`. ### `one guide [topic]` diff --git a/skills/one/SKILL.md b/skills/one/SKILL.md index 31e1549..3e92e63 100644 --- a/skills/one/SKILL.md +++ b/skills/one/SKILL.md @@ -377,10 +377,10 @@ To create the Connect app itself without the dashboard, write `one.connect.ts` w ```bash one --agent connect types # tool slugs and action names autocomplete in one.connect.ts -one --agent connect create # creates or updates the app; writes ONE_CLIENT_ID, ONE_CLIENT_SECRET, ONE_PERMISSION_SET, ONE_REDIRECT_URI to .env +one --agent connect create # creates or updates the app; writes ONE_CLIENT_ID, ONE_PERMISSION_SET, ONE_REDIRECT_URI to .env ``` -The client secret goes straight into the env file and never appears in the output; do not read it back. It opens the app's Credentials page for the human: tell them to copy each key in `pending` (usually `ONE_CONNECT_KEY`) from there into the env file the result names. Run `connect create` again after editing the file to update the same app. +It opens the app's Credentials page for the human: tell them to copy `ONE_CLIENT_SECRET` and `ONE_CONNECT_KEY` from there into the env file the result names (`pending`). The CLI never receives either secret. Run `connect create` again after editing the file to update the same app. ## Adding New Connections diff --git a/src/cli.ts b/src/cli.ts index 52adb7a..879d885 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -97,7 +97,7 @@ program one skills remove connect Remove it Connect apps (one.connect.ts, typed by @withone/connect/config): - one connect create Create or update the app, write .env, open the page for its connect key + one connect create Create or update the app, write .env, open the page with its two secrets one connect types Write one-connect-env.d.ts so tool slugs and action names autocomplete Guide: @@ -671,7 +671,7 @@ const connectApp = program connectApp .command('create') - .description('Create the app (or update it, once .env holds its ONE_CLIENT_ID), write .env (the client secret is written, never printed), and open the page that makes its connect key') + .description('Create the app (or update it, once .env holds its ONE_CLIENT_ID), write .env, and open the page that shows its two secrets once') .option('--config ', 'Config file (default: one.connect.ts, .mts, .mjs or .js in this directory)') .option('--env-file ', 'Env file to write (default: .env.local if it exists, else .env)') .option('--no-open', 'Print the secrets page instead of opening it') diff --git a/src/commands/connect.ts b/src/commands/connect.ts index 661085d..b72375e 100644 --- a/src/commands/connect.ts +++ b/src/commands/connect.ts @@ -125,24 +125,24 @@ export async function connectCreateCommand(options: { config?: string; envFile?: permissionSetId = set.id; } - // The secret comes back once, on creation: it goes straight into the env - // file and is never printed or returned. - const nextEnv = upsertEnv(envText, { - ONE_CLIENT_ID: app.clientId, - ONE_REDIRECT_URI: config.redirectUris[0], - ...(permissionSetId ? { ONE_PERMISSION_SET: permissionSetId } : {}), - ONE_CLIENT_SECRET: app.clientSecret ?? null, - ONE_CONNECT_KEY: null, - }); - fs.writeFileSync(envFile, nextEnv); + fs.writeFileSync( + envFile, + upsertEnv(envText, { + ONE_CLIENT_ID: app.clientId, + ONE_REDIRECT_URI: config.redirectUris[0], + ...(permissionSetId ? { ONE_PERMISSION_SET: permissionSetId } : {}), + ONE_CLIENT_SECRET: null, + ONE_CONNECT_KEY: null, + }) + ); const whoami = await ensureWhoAmI(api); const credentialsUrl = getConnectCredentialsUrl(app.clientId, { orgId: whoami?.organization?.id, projectId: whoami?.project?.id, }); - const pending = (['ONE_CLIENT_SECRET', 'ONE_CONNECT_KEY'] as const).filter((key) => !readEnvValue(nextEnv, key)); - const opening = options.open !== false && pending.length > 0; + const secretsPending = !readEnvValue(envText, 'ONE_CLIENT_SECRET') || !readEnvValue(envText, 'ONE_CONNECT_KEY'); + const opening = options.open !== false && (created || secretsPending); if (opening) await open(credentialsUrl).catch(() => undefined); const result = { @@ -152,7 +152,7 @@ export async function connectCreateCommand(options: { config?: string; envFile?: redirectUris: config.redirectUris, envFile: path.relative(cwd, envFile) || envFile, credentialsUrl, - pending, + pending: secretsPending ? ['ONE_CLIENT_SECRET', 'ONE_CONNECT_KEY'] : [], }; if (output.isAgentMode()) { @@ -166,11 +166,10 @@ export async function connectCreateCommand(options: { config?: string; envFile?: if (permissionSetId) console.log(` ${pc.dim('Permission set:')} ${permissionSetId}`); console.log(` ${pc.dim('Wrote')} ${result.envFile}`); console.log(); - if (pending.length > 0) { - const what = pending.includes('ONE_CLIENT_SECRET') ? 'the connect key and a new client secret' : 'the connect key'; - console.log(` ${opening ? `Opening your browser for ${what}:` : `Get ${what} here:`}`); + if (secretsPending) { + console.log(` ${opening ? 'Opening your browser for the two secrets:' : 'Get the two secrets here:'}`); console.log(` ${pc.underline(credentialsUrl)}`); - console.log(` ${pc.dim(`Paste ${pending.join(' and ')} into`)} ${result.envFile}${pc.dim('. Secrets are never printed here.')}`); + console.log(` ${pc.dim('Paste ONE_CLIENT_SECRET and ONE_CONNECT_KEY into')} ${result.envFile}${pc.dim('. They are never printed here.')}`); console.log(); } } catch (error) { diff --git a/src/lib/api.ts b/src/lib/api.ts index 371c222..54edc75 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -595,8 +595,8 @@ export class OneApi { } } - // Connect app methods. A key creates and updates; the client secret comes - // back once on creation, and the connect key is minted in the dashboard. + // Connect app methods. A key creates and updates; the secret and the + // connect key are minted in the dashboard, never handed to the CLI. async createConnectApp(fields: Record): Promise { return this.requestFull({ diff --git a/src/lib/connect-app.ts b/src/lib/connect-app.ts index ace4aae..5829c4c 100644 --- a/src/lib/connect-app.ts +++ b/src/lib/connect-app.ts @@ -52,11 +52,26 @@ export function findConfigFile(cwd: string, explicit?: string): string | null { return null; } -/** Imports the config's default export. Every Node the CLI runs on strips - * types, so a `.ts` file loads as it is. */ +/** + * Imports the config's default export. A `.ts` file loads natively on + * Node 22.18+ (type stripping); older Node gets a message pointing at the + * `.mjs` form instead of a stack trace. + */ export async function loadConfigFile(file: string): Promise { - const mod = await import(pathToFileURL(file).href); - return mod.default ?? mod; + try { + const mod = await import(pathToFileURL(file).href); + return mod.default ?? mod; + } catch (error) { + const code = (error as { code?: string }).code; + if (code === 'ERR_UNKNOWN_FILE_EXTENSION' && /\.m?ts$/.test(file)) { + throw new Error( + `Node ${process.versions.node} can't load ${path.basename(file)}. Use Node 22.18 or later, ` + + `or rename it to one.connect.mjs and type it with ` + + `/** @type {import("@withone/connect/config").ConnectAppConfig} */.` + ); + } + throw error; + } } /** Checks the shape the API would refuse, so the developer hears it before diff --git a/src/lib/guide-content.ts b/src/lib/guide-content.ts index e6dd4b4..ca88237 100644 --- a/src/lib/guide-content.ts +++ b/src/lib/guide-content.ts @@ -114,7 +114,7 @@ one --agent relay deliveries --endpoint-id # Check delivery - Use \`actions find\` (and \`actions load\` for more) to learn both the incoming payload shape AND the destination API shape before building templates ### One Connect apps — created from \`one.connect.ts\` -\`one connect create\` creates (or, once .env holds its \`ONE_CLIENT_ID\`, updates) a One Connect app from \`one.connect.ts\`, typed by \`defineConnectApp\` from \`@withone/connect/config\`. It writes \`ONE_CLIENT_ID\`, \`ONE_CLIENT_SECRET\` (never printed), \`ONE_PERMISSION_SET\` and \`ONE_REDIRECT_URI\` to the env file and opens the app's Credentials page, where the human copies the connect key. \`one connect types\` writes \`one-connect-env.d.ts\` so tool slugs and action names autocomplete. Each tool's \`access\` is \`read-only\`, \`read-write\`, \`full-access\`, or \`custom\` with \`actions\` (names, or ids from \`actions find\`). +\`one connect create\` creates (or, once .env holds its \`ONE_CLIENT_ID\`, updates) a One Connect app from \`one.connect.ts\`, typed by \`defineConnectApp\` from \`@withone/connect/config\`. It writes \`ONE_CLIENT_ID\`, \`ONE_PERMISSION_SET\` and \`ONE_REDIRECT_URI\` to the env file and opens the app's Credentials page, where the human copies the client secret and connect key; the CLI never receives them. \`one connect types\` writes \`one-connect-env.d.ts\` so tool slugs and action names autocomplete. Each tool's \`access\` is \`read-only\`, \`read-write\`, \`full-access\`, or \`custom\` with \`actions\` (names, or ids from \`actions find\`). ### 4. Memory + Sync — Unified store with hybrid FTS + semantic search One ships a local memory store (a real Postgres process bootstrapped on demand via the bundled \`embedded-postgres\` plugin, with a \`postgres\` plugin for remote/self-hosted) that backs both user-authored notes (\`one mem add\`) and synced platform data (\`one sync run\`). Auto-initializes on first use — no separate install step. Run \`one guide memory\` and \`one guide sync\` for the full references. diff --git a/src/lib/types.ts b/src/lib/types.ts index 509b06d..c5d38a7 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -186,14 +186,12 @@ export interface AvailableAction { method: string; } -/** A Connect app as `/v1/oauth-clients` answers. `clientSecret` is there - * once, on creation; the CLI writes it to the env file and never prints it. */ +/** A Connect app as `/v1/oauth-clients` answers an API key: never a secret. */ export interface ConnectAppView { id: string; clientId: string; name: string; redirectUris: string[]; - clientSecret?: string | null; } export interface ConnectPermissionSetView { From 2b2d35dd64b2446687a58e0e1d6aba81709d179b Mon Sep 17 00:00:00 2001 From: Krish Parekh Date: Wed, 28 Oct 2026 20:07:37 +0530 Subject: [PATCH 4/9] fix(connect): say to install @withone/connect when one.connect.ts can't import it The config imports the SDK from the project, not from the CLI. Node's raw 'Cannot find package' error now reads as an instruction, and an SDK older than 0.19.0 (no /config export) says to upgrade. The skill and guide say to install the SDK before writing the file. Co-Authored-By: Claude Opus 5.5 --- skills/one/SKILL.md | 2 +- src/lib/connect-app.test.ts | 22 ++++++++++++++++++++++ src/lib/connect-app.ts | 15 ++++++++++++++- src/lib/guide-content.ts | 2 +- 4 files changed, 38 insertions(+), 3 deletions(-) diff --git a/skills/one/SKILL.md b/skills/one/SKILL.md index 3e92e63..94842b4 100644 --- a/skills/one/SKILL.md +++ b/skills/one/SKILL.md @@ -373,7 +373,7 @@ one --agent skills add connect # from the @withone/connect version the app It lands in `.agents/skills/one-connect` (and `.claude/skills/one-connect`). The result's `next` array says what to do; the skill covers the button, the two routes, key mode vs token mode, and calling One with the grant. Never ask the user to paste the client secret or connect key into chat. -To create the Connect app itself without the dashboard, write `one.connect.ts` with `defineConnectApp` from `@withone/connect/config` (name, `redirectUris`, `ask` per tool with `access`: `read-only`, `read-write`, `full-access`, or `custom` with `actions`), then: +To create the Connect app itself without the dashboard, install `@withone/connect` in the project (0.19.0 or later; the file imports it from there, not from the CLI), write `one.connect.ts` with `defineConnectApp` from `@withone/connect/config` (name, `redirectUris`, `ask` per tool with `access`: `read-only`, `read-write`, `full-access`, or `custom` with `actions`), then: ```bash one --agent connect types # tool slugs and action names autocomplete in one.connect.ts diff --git a/src/lib/connect-app.test.ts b/src/lib/connect-app.test.ts index af357d5..66570f5 100644 --- a/src/lib/connect-app.test.ts +++ b/src/lib/connect-app.test.ts @@ -154,6 +154,28 @@ describe('config files', () => { fs.rmSync(dir, { recursive: true, force: true }); } }); + + it('says to install the SDK when the project has none, or one without /config', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'one-connect-')); + try { + const file = path.join(dir, 'one.connect.mjs'); + fs.writeFileSync( + file, + 'import { defineConnectApp } from "@withone/connect/config";\nexport default defineConnectApp({ name: "Mastra" });\n' + ); + await assert.rejects(loadConfigFile(file), /isn't installed in this project\. Run `npm i @withone\/connect` first/); + + const sdk = path.join(dir, 'node_modules', '@withone', 'connect'); + fs.mkdirSync(sdk, { recursive: true }); + fs.writeFileSync( + path.join(sdk, 'package.json'), + JSON.stringify({ name: '@withone/connect', version: '0.18.0', exports: { '.': './index.js' } }) + ); + await assert.rejects(loadConfigFile(file), /older than 0\.19\.0.*npm i @withone\/connect@latest/); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); }); describe('renderTypes', () => { diff --git a/src/lib/connect-app.ts b/src/lib/connect-app.ts index 5829c4c..cc452cd 100644 --- a/src/lib/connect-app.ts +++ b/src/lib/connect-app.ts @@ -62,7 +62,20 @@ export async function loadConfigFile(file: string): Promise { const mod = await import(pathToFileURL(file).href); return mod.default ?? mod; } catch (error) { - const code = (error as { code?: string }).code; + const { code, message = '' } = error as { code?: string; message?: string }; + // The config imports the SDK from the project, not from the CLI. + if (code === 'ERR_MODULE_NOT_FOUND' && message.includes("'@withone/connect'")) { + throw new Error( + `it imports @withone/connect, which isn't installed in this project. ` + + `Run \`npm i @withone/connect\` first.` + ); + } + if (code === 'ERR_PACKAGE_PATH_NOT_EXPORTED' && message.includes('@withone/connect')) { + throw new Error( + `this project's @withone/connect is older than 0.19.0, which added @withone/connect/config. ` + + `Run \`npm i @withone/connect@latest\` first.` + ); + } if (code === 'ERR_UNKNOWN_FILE_EXTENSION' && /\.m?ts$/.test(file)) { throw new Error( `Node ${process.versions.node} can't load ${path.basename(file)}. Use Node 22.18 or later, ` + diff --git a/src/lib/guide-content.ts b/src/lib/guide-content.ts index ca88237..66675f5 100644 --- a/src/lib/guide-content.ts +++ b/src/lib/guide-content.ts @@ -114,7 +114,7 @@ one --agent relay deliveries --endpoint-id # Check delivery - Use \`actions find\` (and \`actions load\` for more) to learn both the incoming payload shape AND the destination API shape before building templates ### One Connect apps — created from \`one.connect.ts\` -\`one connect create\` creates (or, once .env holds its \`ONE_CLIENT_ID\`, updates) a One Connect app from \`one.connect.ts\`, typed by \`defineConnectApp\` from \`@withone/connect/config\`. It writes \`ONE_CLIENT_ID\`, \`ONE_PERMISSION_SET\` and \`ONE_REDIRECT_URI\` to the env file and opens the app's Credentials page, where the human copies the client secret and connect key; the CLI never receives them. \`one connect types\` writes \`one-connect-env.d.ts\` so tool slugs and action names autocomplete. Each tool's \`access\` is \`read-only\`, \`read-write\`, \`full-access\`, or \`custom\` with \`actions\` (names, or ids from \`actions find\`). +\`one connect create\` creates (or, once .env holds its \`ONE_CLIENT_ID\`, updates) a One Connect app from \`one.connect.ts\`, typed by \`defineConnectApp\` from \`@withone/connect/config\`; install \`@withone/connect\` (0.19.0 or later) in the project first. It writes \`ONE_CLIENT_ID\`, \`ONE_PERMISSION_SET\` and \`ONE_REDIRECT_URI\` to the env file and opens the app's Credentials page, where the human copies the client secret and connect key; the CLI never receives them. \`one connect types\` writes \`one-connect-env.d.ts\` so tool slugs and action names autocomplete. Each tool's \`access\` is \`read-only\`, \`read-write\`, \`full-access\`, or \`custom\` with \`actions\` (names, or ids from \`actions find\`). ### 4. Memory + Sync — Unified store with hybrid FTS + semantic search One ships a local memory store (a real Postgres process bootstrapped on demand via the bundled \`embedded-postgres\` plugin, with a \`postgres\` plugin for remote/self-hosted) that backs both user-authored notes (\`one mem add\`) and synced platform data (\`one sync run\`). Auto-initializes on first use — no separate install step. Run \`one guide memory\` and \`one guide sync\` for the full references. From ef1d4562381febf23d8fd825c3121356f080b02d Mon Sep 17 00:00:00 2001 From: Krish Parekh Date: Wed, 28 Oct 2026 20:40:32 +0530 Subject: [PATCH 5/9] feat(connect): open the /cli/connect setup page and name the env file it should say connect create now opens /cli/connect/ (beside one login's /cli/auth) with ?env=, so the page tells the developer where to paste the two secrets. Docs call it the setup page. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- skills/one/SKILL.md | 2 +- src/commands/connect.ts | 1 + src/lib/browser.test.ts | 8 ++++---- src/lib/browser.ts | 15 +++++++++++---- src/lib/guide-content.ts | 2 +- 6 files changed, 19 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index a38bc9e..2cf379e 100644 --- a/README.md +++ b/README.md @@ -536,7 +536,7 @@ one connect types # writes one-connect-env.d.ts: tool slugs and action names one connect create # creates the app, or updates it once .env holds its ONE_CLIENT_ID ``` -`one connect create` uses your API key, writes `ONE_CLIENT_ID`, `ONE_PERMISSION_SET` and `ONE_REDIRECT_URI` to `.env.local` (or `.env`), and opens the app's **Credentials** page in the dashboard, which shows the client secret and the connect key once. The two secrets never pass through the terminal. Each tool in `ask` takes `access`: `read-only`, `read-write`, `full-access`, or `custom` with `actions`, named as `one connect types` wrote them or by id from `one actions search`. Options: `--config `, `--env-file `, `--no-open`. A `.ts` config needs Node 22.18 or later; on older Node use `one.connect.mjs`. +`one connect create` uses your API key, writes `ONE_CLIENT_ID`, `ONE_PERMISSION_SET` and `ONE_REDIRECT_URI` to `.env.local` (or `.env`), and opens a setup page in your signed-in browser, which makes the client secret and the connect key and shows them once. The two secrets never pass through the terminal. Each tool in `ask` takes `access`: `read-only`, `read-write`, `full-access`, or `custom` with `actions`, named as `one connect types` wrote them or by id from `one actions search`. Options: `--config `, `--env-file `, `--no-open`. Install `@withone/connect` (0.19.0 or later) in the project first. A `.ts` config needs Node 22.18 or later; on older Node use `one.connect.mjs`. ### `one guide [topic]` diff --git a/skills/one/SKILL.md b/skills/one/SKILL.md index 94842b4..e7a46dd 100644 --- a/skills/one/SKILL.md +++ b/skills/one/SKILL.md @@ -380,7 +380,7 @@ one --agent connect types # tool slugs and action names autocomplete in one one --agent connect create # creates or updates the app; writes ONE_CLIENT_ID, ONE_PERMISSION_SET, ONE_REDIRECT_URI to .env ``` -It opens the app's Credentials page for the human: tell them to copy `ONE_CLIENT_SECRET` and `ONE_CONNECT_KEY` from there into the env file the result names (`pending`). The CLI never receives either secret. Run `connect create` again after editing the file to update the same app. +It opens a setup page in the human's browser: tell them to copy `ONE_CLIENT_SECRET` and `ONE_CONNECT_KEY` from there into the env file the result names (`pending`). The CLI never receives either secret. Run `connect create` again after editing the file to update the same app. ## Adding New Connections diff --git a/src/commands/connect.ts b/src/commands/connect.ts index b72375e..9ec38ea 100644 --- a/src/commands/connect.ts +++ b/src/commands/connect.ts @@ -140,6 +140,7 @@ export async function connectCreateCommand(options: { config?: string; envFile?: const credentialsUrl = getConnectCredentialsUrl(app.clientId, { orgId: whoami?.organization?.id, projectId: whoami?.project?.id, + envFile: path.basename(envFile), }); const secretsPending = !readEnvValue(envText, 'ONE_CLIENT_SECRET') || !readEnvValue(envText, 'ONE_CONNECT_KEY'); const opening = options.open !== false && (created || secretsPending); diff --git a/src/lib/browser.test.ts b/src/lib/browser.test.ts index f68ca9e..33dce8c 100644 --- a/src/lib/browser.test.ts +++ b/src/lib/browser.test.ts @@ -67,11 +67,11 @@ describe('getConnectCredentialsUrl', () => { else process.env.ONE_APP_URL = saved; }); - it('opens the app’s credentials page in the space it was created in', () => { + it('opens the setup page in the space the app was created in, naming the env file', () => { assert.equal( - getConnectCredentialsUrl('f305b9fc', { orgId: 'org-1', projectId: 'proj-1' }), - 'https://app.withone.ai/developers/connect/f305b9fc/credentials?orgId=org-1&projectId=proj-1' + getConnectCredentialsUrl('f305b9fc', { orgId: 'org-1', projectId: 'proj-1', envFile: '.env.local' }), + 'https://app.withone.ai/cli/connect/f305b9fc?orgId=org-1&projectId=proj-1&env=.env.local' ); - assert.equal(getConnectCredentialsUrl('f305b9fc'), 'https://app.withone.ai/developers/connect/f305b9fc/credentials'); + assert.equal(getConnectCredentialsUrl('f305b9fc'), 'https://app.withone.ai/cli/connect/f305b9fc'); }); }); diff --git a/src/lib/browser.ts b/src/lib/browser.ts index a4c2741..4360c7c 100644 --- a/src/lib/browser.ts +++ b/src/lib/browser.ts @@ -34,16 +34,23 @@ export function getApiKeyUrl(): string { } /** - * The page that mints a Connect app's client secret and connect key and + * The browser half of `one connect create`, beside `one login`'s + * `/cli/auth`: mints a Connect app's client secret and connect key and * shows them once. The org and project name the space the app was created - * in, so the page reads it from there whatever the header has selected. + * in, so the page reads it from there whatever the dashboard has + * selected; `env` names the file the CLI wrote, so the page says where to + * paste. */ -export function getConnectCredentialsUrl(clientId: string, params?: Pick): string { +export function getConnectCredentialsUrl( + clientId: string, + params?: Pick & { envFile?: string } +): string { const searchParams = new URLSearchParams(); if (params?.orgId) searchParams.set('orgId', params.orgId); if (params?.projectId) searchParams.set('projectId', params.projectId); + if (params?.envFile) searchParams.set('env', params.envFile); const qs = searchParams.toString(); - return `${oneAppUrl()}/developers/connect/${encodeURIComponent(clientId)}/credentials${qs ? `?${qs}` : ''}`; + return `${oneAppUrl()}/cli/connect/${encodeURIComponent(clientId)}${qs ? `?${qs}` : ''}`; } export async function openConnectionPage(platform: string, params?: ConnectionUrlParams): Promise { diff --git a/src/lib/guide-content.ts b/src/lib/guide-content.ts index 66675f5..632b34d 100644 --- a/src/lib/guide-content.ts +++ b/src/lib/guide-content.ts @@ -114,7 +114,7 @@ one --agent relay deliveries --endpoint-id # Check delivery - Use \`actions find\` (and \`actions load\` for more) to learn both the incoming payload shape AND the destination API shape before building templates ### One Connect apps — created from \`one.connect.ts\` -\`one connect create\` creates (or, once .env holds its \`ONE_CLIENT_ID\`, updates) a One Connect app from \`one.connect.ts\`, typed by \`defineConnectApp\` from \`@withone/connect/config\`; install \`@withone/connect\` (0.19.0 or later) in the project first. It writes \`ONE_CLIENT_ID\`, \`ONE_PERMISSION_SET\` and \`ONE_REDIRECT_URI\` to the env file and opens the app's Credentials page, where the human copies the client secret and connect key; the CLI never receives them. \`one connect types\` writes \`one-connect-env.d.ts\` so tool slugs and action names autocomplete. Each tool's \`access\` is \`read-only\`, \`read-write\`, \`full-access\`, or \`custom\` with \`actions\` (names, or ids from \`actions find\`). +\`one connect create\` creates (or, once .env holds its \`ONE_CLIENT_ID\`, updates) a One Connect app from \`one.connect.ts\`, typed by \`defineConnectApp\` from \`@withone/connect/config\`; install \`@withone/connect\` (0.19.0 or later) in the project first. It writes \`ONE_CLIENT_ID\`, \`ONE_PERMISSION_SET\` and \`ONE_REDIRECT_URI\` to the env file and opens a setup page in the browser, where the human makes and copies the client secret and connect key; the CLI never receives them. \`one connect types\` writes \`one-connect-env.d.ts\` so tool slugs and action names autocomplete. Each tool's \`access\` is \`read-only\`, \`read-write\`, \`full-access\`, or \`custom\` with \`actions\` (names, or ids from \`actions find\`). ### 4. Memory + Sync — Unified store with hybrid FTS + semantic search One ships a local memory store (a real Postgres process bootstrapped on demand via the bundled \`embedded-postgres\` plugin, with a \`postgres\` plugin for remote/self-hosted) that backs both user-authored notes (\`one mem add\`) and synced platform data (\`one sync run\`). Auto-initializes on first use — no separate install step. Run \`one guide memory\` and \`one guide sync\` for the full references. From a08dabed13a4405969203c379325381043457978 Mon Sep 17 00:00:00 2001 From: Krish Parekh Date: Wed, 28 Oct 2026 21:17:58 +0530 Subject: [PATCH 6/9] feat(connect): hand the config to a browser review instead of writing it with the API key connect create resolves the config to catalog ids, then opens the setup page with it in the link's fragment (/cli/connect/new, or the app's id to review changes) and waits on 127.0.0.1, like one login. The person approves with their dashboard session; the page reports the ids back and the CLI writes them to the env file. The API key only reads the catalog. Drops the create/update API methods. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- skills/one/SKILL.md | 4 +- src/cli.ts | 6 +- src/commands/connect.ts | 128 +++++++++++++++++-------------- src/lib/api.ts | 45 ----------- src/lib/browser.test.ts | 25 ++++-- src/lib/browser.ts | 60 +++++++++++---- src/lib/connect-app.test.ts | 27 +++++++ src/lib/connect-app.ts | 23 ++++++ src/lib/connect-callback.test.ts | 53 +++++++++++++ src/lib/connect-callback.ts | 128 +++++++++++++++++++++++++++++++ src/lib/guide-content.ts | 2 +- src/lib/types.ts | 13 ---- 13 files changed, 377 insertions(+), 141 deletions(-) create mode 100644 src/lib/connect-callback.test.ts create mode 100644 src/lib/connect-callback.ts diff --git a/README.md b/README.md index 2cf379e..d65a559 100644 --- a/README.md +++ b/README.md @@ -533,10 +533,10 @@ Create and update a [One Connect](https://www.npmjs.com/package/@withone/connect ```bash one connect types # writes one-connect-env.d.ts: tool slugs and action names autocomplete -one connect create # creates the app, or updates it once .env holds its ONE_CLIENT_ID +one connect create # approve the app in the browser; re-run to review and apply changes ``` -`one connect create` uses your API key, writes `ONE_CLIENT_ID`, `ONE_PERMISSION_SET` and `ONE_REDIRECT_URI` to `.env.local` (or `.env`), and opens a setup page in your signed-in browser, which makes the client secret and the connect key and shows them once. The two secrets never pass through the terminal. Each tool in `ask` takes `access`: `read-only`, `read-write`, `full-access`, or `custom` with `actions`, named as `one connect types` wrote them or by id from `one actions search`. Options: `--config `, `--env-file `, `--no-open`. Install `@withone/connect` (0.19.0 or later) in the project first. A `.ts` config needs Node 22.18 or later; on older Node use `one.connect.mjs`. +`one connect create` opens a setup page in your signed-in browser, where you review the app (or, on a re-run, the changes) and approve it; nothing is created or changed with your API key. A new app's client secret and connect key are made there and shown once, so they never pass through the terminal. Once you approve, the CLI writes `ONE_CLIENT_ID`, `ONE_PERMISSION_SET` and `ONE_REDIRECT_URI` to `.env.local` (or `.env`). Each tool in `ask` takes `access`: `read-only`, `read-write`, `full-access`, or `custom` with `actions`, named as `one connect types` wrote them or by id from `one actions search`. Options: `--config `, `--env-file `, `--no-open`. Install `@withone/connect` (0.19.0 or later) in the project first. A `.ts` config needs Node 22.18 or later; on older Node use `one.connect.mjs`. ### `one guide [topic]` diff --git a/skills/one/SKILL.md b/skills/one/SKILL.md index e7a46dd..bc9f4a8 100644 --- a/skills/one/SKILL.md +++ b/skills/one/SKILL.md @@ -377,10 +377,10 @@ To create the Connect app itself without the dashboard, install `@withone/connec ```bash one --agent connect types # tool slugs and action names autocomplete in one.connect.ts -one --agent connect create # creates or updates the app; writes ONE_CLIENT_ID, ONE_PERMISSION_SET, ONE_REDIRECT_URI to .env +one --agent connect create # the human approves it in the browser; then writes ONE_CLIENT_ID, ONE_PERMISSION_SET, ONE_REDIRECT_URI to .env ``` -It opens a setup page in the human's browser: tell them to copy `ONE_CLIENT_SECRET` and `ONE_CONNECT_KEY` from there into the env file the result names (`pending`). The CLI never receives either secret. Run `connect create` again after editing the file to update the same app. +It opens a setup page in the human's browser and waits up to 10 minutes, so run it in the background or with a long timeout; the link is printed to stderr at once, so pass it on if no browser opened. The human reviews the app (or, on a re-run, the changes) and approves it with their dashboard session: an API key never creates or changes a Connect app. For a new app the page also makes `ONE_CLIENT_SECRET` and `ONE_CONNECT_KEY` and shows them once; tell the human to copy them into the env file the result names. The CLI never receives either secret. Run `connect create` again after editing the file to update the same app. ## Adding New Connections diff --git a/src/cli.ts b/src/cli.ts index 879d885..a85d2e1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -97,7 +97,7 @@ program one skills remove connect Remove it Connect apps (one.connect.ts, typed by @withone/connect/config): - one connect create Create or update the app, write .env, open the page with its two secrets + one connect create Approve the app (or its changes) in the browser, then write .env one connect types Write one-connect-env.d.ts so tool slugs and action names autocomplete Guide: @@ -671,10 +671,10 @@ const connectApp = program connectApp .command('create') - .description('Create the app (or update it, once .env holds its ONE_CLIENT_ID), write .env, and open the page that shows its two secrets once') + .description('Open the setup page to approve the app (or, once .env holds its ONE_CLIENT_ID, its changes); a new app gets its two secrets there. Then write .env') .option('--config ', 'Config file (default: one.connect.ts, .mts, .mjs or .js in this directory)') .option('--env-file ', 'Env file to write (default: .env.local if it exists, else .env)') - .option('--no-open', 'Print the secrets page instead of opening it') + .option('--no-open', 'Print the setup page link instead of opening it') .action(async (options: { config?: string; envFile?: string; open?: boolean }) => { await connectCreateCommand(options); }); diff --git a/src/commands/connect.ts b/src/commands/connect.ts index 9ec38ea..2b1222f 100644 --- a/src/commands/connect.ts +++ b/src/commands/connect.ts @@ -1,15 +1,19 @@ +import crypto from 'node:crypto'; import fs from 'node:fs'; +import http from 'node:http'; import path from 'node:path'; import open from 'open'; import pc from 'picocolors'; -import { ApiError, OneApi } from '../lib/api.js'; -import { getConnectCredentialsUrl } from '../lib/browser.js'; +import { OneApi } from '../lib/api.js'; +import { getConnectDoneUrl, getConnectSetupUrl } from '../lib/browser.js'; +import { startConnectCallbackServer } from '../lib/connect-callback.js'; import { ensureWhoAmI, getApiBase, getApiKey } from '../lib/config.js'; import { TYPES_FILE, actionNames, - clientFields, + buildHandoff, connectorRule, + encodeHandoff, defaultEnvFile, findConfigFile, loadConfigFile, @@ -23,7 +27,8 @@ import { } from '../lib/connect-app.js'; import * as output from '../lib/output.js'; -const ASK_NAME = 'Default permission set'; +/** As long as the person may need to read and approve the page. */ +const SETUP_TIMEOUT_MS = 10 * 60 * 1000; function requireApi(): OneApi { const apiKey = getApiKey(); @@ -93,89 +98,100 @@ export async function connectCreateCommand(options: { config?: string; envFile?: const { file, config } = await readConfig(cwd, options.config); const envFile = options.envFile ? path.resolve(cwd, options.envFile) : defaultEnvFile(cwd); const envText = fs.existsSync(envFile) ? fs.readFileSync(envFile, 'utf8') : ''; + const existingId = readEnvValue(envText, 'ONE_CLIENT_ID') || undefined; const spinner = output.createSpinner(); spinner.start(`Reading ${path.basename(file)}...`); + let server: http.Server | undefined; + let timer: NodeJS.Timeout | undefined; try { const rules = await buildRules(api, config); - const fields = clientFields(config); - const existingId = readEnvValue(envText, 'ONE_CLIENT_ID'); - - let app; - let created = false; - if (existingId) { - try { - app = await api.updateConnectApp(existingId, fields); - } catch (error) { - if (!(error instanceof ApiError && error.status === 404)) throw error; - } - } - if (!app) { - app = await api.createConnectApp(fields); - created = true; - } + const whoami = await ensureWhoAmI(api); + const scope = { + orgId: whoami?.organization?.id, + projectId: whoami?.project?.id, + envFile: path.basename(envFile), + }; + + const state = crypto.randomBytes(16).toString('hex'); + const started = await startConnectCallbackServer(state, (done) => + getConnectDoneUrl(done.clientId, done.outcome, scope) + ); + server = started.server; + const url = getConnectSetupUrl({ + ...scope, + clientId: existingId, + permissionSetId: readEnvValue(envText, 'ONE_PERMISSION_SET') || undefined, + port: started.port, + state, + handoff: encodeHandoff(buildHandoff(config, rules)), + }); - let permissionSetId = created ? undefined : readEnvValue(envText, 'ONE_PERMISSION_SET') || undefined; - if (rules.length > 0) { - const body = { name: ASK_NAME, connectorRules: rules }; - const set = permissionSetId - ? await api.updatePermissionSet(app.clientId, permissionSetId, body) - : await api.createPermissionSet(app.clientId, body); - permissionSetId = set.id; + const opening = options.open !== false; + if (opening) await open(url).catch(() => undefined); + const verb = existingId ? 'Review the changes' : 'Create the app'; + if (output.isAgentMode()) { + // The JSON result comes only once the person approves; the link goes + // out now so an agent can hand it over if no browser opened. + process.stderr.write(`${verb} in the browser: ${url}\n`); + } else { + spinner.stop(`${verb} in your browser`); + console.log(` ${pc.underline(url)}`); + console.log(); + spinner.start('Waiting for you to approve it...'); } + const outcome = await Promise.race([ + started.result, + new Promise((resolve) => { + timer = setTimeout(() => resolve(null), SETUP_TIMEOUT_MS); + }), + ]); + if (!outcome) output.error('Timed out waiting for the browser. Run `one connect create` again.'); + if (outcome.kind === 'cancelled') output.error('Cancelled in the browser. Nothing was changed.'); + if (outcome.kind === 'failed') output.error(`The setup page reported: ${outcome.reason}`); + + const created = outcome.outcome === 'created'; fs.writeFileSync( envFile, upsertEnv(envText, { - ONE_CLIENT_ID: app.clientId, + ONE_CLIENT_ID: outcome.clientId, ONE_REDIRECT_URI: config.redirectUris[0], - ...(permissionSetId ? { ONE_PERMISSION_SET: permissionSetId } : {}), - ONE_CLIENT_SECRET: null, - ONE_CONNECT_KEY: null, + ...(outcome.permissionSetId ? { ONE_PERMISSION_SET: outcome.permissionSetId } : {}), + ...(created ? { ONE_CLIENT_SECRET: null, ONE_CONNECT_KEY: null } : {}), }) ); - const whoami = await ensureWhoAmI(api); - const credentialsUrl = getConnectCredentialsUrl(app.clientId, { - orgId: whoami?.organization?.id, - projectId: whoami?.project?.id, - envFile: path.basename(envFile), - }); - const secretsPending = !readEnvValue(envText, 'ONE_CLIENT_SECRET') || !readEnvValue(envText, 'ONE_CONNECT_KEY'); - const opening = options.open !== false && (created || secretsPending); - if (opening) await open(credentialsUrl).catch(() => undefined); - const result = { - created, - clientId: app.clientId, - permissionSetId: permissionSetId ?? null, + outcome: outcome.outcome, + clientId: outcome.clientId, + permissionSetId: outcome.permissionSetId, redirectUris: config.redirectUris, envFile: path.relative(cwd, envFile) || envFile, - credentialsUrl, - pending: secretsPending ? ['ONE_CLIENT_SECRET', 'ONE_CONNECT_KEY'] : [], }; - if (output.isAgentMode()) { output.json(result); return; } - spinner.stop(created ? `Created ${config.name}` : `Updated ${config.name}`); + spinner.stop( + created ? `Created ${config.name}` : outcome.outcome === 'updated' ? `Updated ${config.name}` : `${config.name} is up to date` + ); console.log(); - console.log(` ${pc.dim('Client ID:')} ${app.clientId}`); - if (permissionSetId) console.log(` ${pc.dim('Permission set:')} ${permissionSetId}`); + console.log(` ${pc.dim('Client ID:')} ${outcome.clientId}`); + if (outcome.permissionSetId) console.log(` ${pc.dim('Permission set:')} ${outcome.permissionSetId}`); console.log(` ${pc.dim('Wrote')} ${result.envFile}`); - console.log(); - if (secretsPending) { - console.log(` ${opening ? 'Opening your browser for the two secrets:' : 'Get the two secrets here:'}`); - console.log(` ${pc.underline(credentialsUrl)}`); - console.log(` ${pc.dim('Paste ONE_CLIENT_SECRET and ONE_CONNECT_KEY into')} ${result.envFile}${pc.dim('. They are never printed here.')}`); - console.log(); + if (created) { + console.log(` ${pc.dim('The two secrets came from the browser page, never through here.')}`); } + console.log(); } catch (error) { spinner.stop('Failed'); output.error(`Error: ${message(error)}`); + } finally { + if (timer) clearTimeout(timer); + server?.close(); } } diff --git a/src/lib/api.ts b/src/lib/api.ts index 54edc75..0c24af3 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -19,8 +19,6 @@ import type { FindIntent, FoundActions, KeyAccessDocument, - ConnectAppView, - ConnectPermissionSetView, } from './types.js'; export class ApiError extends Error { @@ -595,49 +593,6 @@ export class OneApi { } } - // Connect app methods. A key creates and updates; the secret and the - // connect key are minted in the dashboard, never handed to the CLI. - - async createConnectApp(fields: Record): Promise { - return this.requestFull({ - path: '/oauth-clients', - method: 'POST', - body: { ...fields, clientType: 'confidential', consentSurface: 'connect' }, - }); - } - - async getConnectApp(clientId: string): Promise { - return this.request(`/oauth-clients/${encodeURIComponent(clientId)}`); - } - - async updateConnectApp(clientId: string, fields: Record): Promise { - return this.requestFull({ - path: `/oauth-clients/${encodeURIComponent(clientId)}`, - method: 'PATCH', - body: fields, - }); - } - - async createPermissionSet(clientId: string, body: { name: string; connectorRules: unknown[] }): Promise { - return this.requestFull({ - path: `/oauth-clients/${encodeURIComponent(clientId)}/permission-sets`, - method: 'POST', - body, - }); - } - - async updatePermissionSet( - clientId: string, - setId: string, - body: { name: string; connectorRules: unknown[] } - ): Promise { - return this.requestFull({ - path: `/oauth-clients/${encodeURIComponent(clientId)}/permission-sets/${encodeURIComponent(setId)}`, - method: 'PATCH', - body, - }); - } - /** Every action on a platform, page by page, following the server's `pages`. */ async listAllAvailableActions(platform: string): Promise { const all: AvailableAction[] = []; diff --git a/src/lib/browser.test.ts b/src/lib/browser.test.ts index 33dce8c..8020376 100644 --- a/src/lib/browser.test.ts +++ b/src/lib/browser.test.ts @@ -1,6 +1,6 @@ import { describe, it, beforeEach, afterEach } from 'node:test'; import assert from 'node:assert/strict'; -import { getApiKeyUrl, getCliAuthUrl, getConnectCredentialsUrl, getConnectionUrl, oneAppUrl } from './browser.js'; +import { getApiKeyUrl, getCliAuthUrl, getConnectDoneUrl, getConnectSetupUrl, getConnectionUrl, oneAppUrl } from './browser.js'; describe('oneAppUrl', () => { let saved: string | undefined; @@ -59,7 +59,7 @@ describe('getCliAuthUrl', () => { }); }); -describe('getConnectCredentialsUrl', () => { +describe('connect setup links', () => { let saved: string | undefined; beforeEach(() => { saved = process.env.ONE_APP_URL; delete process.env.ONE_APP_URL; }); afterEach(() => { @@ -67,11 +67,24 @@ describe('getConnectCredentialsUrl', () => { else process.env.ONE_APP_URL = saved; }); - it('opens the setup page in the space the app was created in, naming the env file', () => { + it('opens /cli/connect/new for a new app, the config in the fragment', () => { assert.equal( - getConnectCredentialsUrl('f305b9fc', { orgId: 'org-1', projectId: 'proj-1', envFile: '.env.local' }), - 'https://app.withone.ai/cli/connect/f305b9fc?orgId=org-1&projectId=proj-1&env=.env.local' + getConnectSetupUrl({ orgId: 'org-1', envFile: '.env.local', port: 51234, state: 'abc', handoff: 'eyJ2IjoxfQ' }), + 'https://app.withone.ai/cli/connect/new?orgId=org-1&env=.env.local&port=51234&state=abc#c=eyJ2IjoxfQ' + ); + }); + + it('opens the app itself for an update, naming its ask', () => { + assert.equal( + getConnectSetupUrl({ clientId: 'f305b9fc', permissionSetId: 'ps-1', port: 1, state: 's', handoff: 'x' }), + 'https://app.withone.ai/cli/connect/f305b9fc?set=ps-1&port=1&state=s#c=x' + ); + }); + + it('sends the browser back to the summary once .env is written', () => { + assert.equal( + getConnectDoneUrl('f305b9fc', 'created', { orgId: 'org-1', projectId: 'proj-1', envFile: '.env' }), + 'https://app.withone.ai/cli/connect/f305b9fc?orgId=org-1&projectId=proj-1&env=.env&outcome=created' ); - assert.equal(getConnectCredentialsUrl('f305b9fc'), 'https://app.withone.ai/cli/connect/f305b9fc'); }); }); diff --git a/src/lib/browser.ts b/src/lib/browser.ts index 4360c7c..046a95a 100644 --- a/src/lib/browser.ts +++ b/src/lib/browser.ts @@ -33,24 +33,58 @@ export function getApiKeyUrl(): string { return `${oneAppUrl()}/settings/api-keys`; } +/** The space and env file every setup-page link names. */ +export interface ConnectSetupScope { + orgId?: string; + projectId?: string; + envFile?: string; +} + +function connectSetupParams(scope: ConnectSetupScope, extra: [string, string | undefined][]): string { + const params = new URLSearchParams(); + if (scope.orgId) params.set('orgId', scope.orgId); + if (scope.projectId) params.set('projectId', scope.projectId); + if (scope.envFile) params.set('env', scope.envFile); + for (const [key, value] of extra) if (value) params.set(key, value); + const qs = params.toString(); + return qs ? `?${qs}` : ''; +} + /** * The browser half of `one connect create`, beside `one login`'s - * `/cli/auth`: mints a Connect app's client secret and connect key and - * shows them once. The org and project name the space the app was created - * in, so the page reads it from there whatever the dashboard has - * selected; `env` names the file the CLI wrote, so the page says where to - * paste. + * `/cli/auth`. The person reviews the app (new, or the changes to an + * existing one) and approves it with their dashboard session; a new app's + * secrets are made and shown there once. The org and project pin the space + * whatever the dashboard has selected, `env` names the file to paste into, + * `port` and `state` are where the page reports back, and the config rides + * in the fragment so no server ever logs it. */ -export function getConnectCredentialsUrl( +export function getConnectSetupUrl( + opts: ConnectSetupScope & { + clientId?: string; + permissionSetId?: string; + port: number; + state: string; + handoff: string; + } +): string { + const target = opts.clientId ? encodeURIComponent(opts.clientId) : 'new'; + const qs = connectSetupParams(opts, [ + ['set', opts.permissionSetId], + ['port', String(opts.port)], + ['state', opts.state], + ]); + return `${oneAppUrl()}/cli/connect/${target}${qs}#c=${opts.handoff}`; +} + +/** Where the CLI sends the browser once it has written `.env`: the setup + * page's summary for that app. */ +export function getConnectDoneUrl( clientId: string, - params?: Pick & { envFile?: string } + outcome: 'created' | 'updated' | 'unchanged', + scope: ConnectSetupScope ): string { - const searchParams = new URLSearchParams(); - if (params?.orgId) searchParams.set('orgId', params.orgId); - if (params?.projectId) searchParams.set('projectId', params.projectId); - if (params?.envFile) searchParams.set('env', params.envFile); - const qs = searchParams.toString(); - return `${oneAppUrl()}/cli/connect/${encodeURIComponent(clientId)}${qs ? `?${qs}` : ''}`; + return `${oneAppUrl()}/cli/connect/${encodeURIComponent(clientId)}${connectSetupParams(scope, [['outcome', outcome]])}`; } export async function openConnectionPage(platform: string, params?: ConnectionUrlParams): Promise { diff --git a/src/lib/connect-app.test.ts b/src/lib/connect-app.test.ts index 66570f5..e42e40c 100644 --- a/src/lib/connect-app.test.ts +++ b/src/lib/connect-app.test.ts @@ -6,6 +6,8 @@ import path from 'node:path'; import { actionName, actionNames, + buildHandoff, + encodeHandoff, clientFields, connectorRule, findConfigFile, @@ -186,3 +188,28 @@ describe('renderTypes', () => { assert.match(text, /"gmail": "createDraft" \| "sendEmail";/); }); }); + +describe('handoff', () => { + const config = { + name: ' Mastra ', + redirectUris: ['http://localhost:3000/api/one/callback'], + reason: 'Plans your day', + }; + + it('carries the app fields and the resolved ask, or null for no ask', () => { + const rule = connectorRule(7, { access: 'read-only' }); + assert.deepEqual(buildHandoff(config, [rule]), { + v: 1, + app: { name: 'Mastra', redirectUris: config.redirectUris, consentReason: 'Plans your day' }, + ask: [rule], + }); + assert.equal(buildHandoff(config, []).ask, null); + }); + + it('encodes as base64url JSON that decodes back', () => { + const handoff = buildHandoff(config, []); + const encoded = encodeHandoff(handoff); + assert.match(encoded, /^[A-Za-z0-9_-]+$/); + assert.deepEqual(JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')), handoff); + }); +}); diff --git a/src/lib/connect-app.ts b/src/lib/connect-app.ts index cc452cd..ff363b1 100644 --- a/src/lib/connect-app.ts +++ b/src/lib/connect-app.ts @@ -199,6 +199,29 @@ export function clientFields(config: ConnectAppConfig): Record return fields; } +/** + * What `one connect create` hands the setup page: the app's fields and its + * ask, already resolved to catalog ids. The page shows it for review and + * writes it with the person's dashboard session, so an API key never + * creates or changes a Connect app. `ask` is null when the config asks for + * nothing, which leaves an existing ask as it is. + */ +export interface ConnectHandoff { + v: 1; + app: Record; + ask: ConnectorRule[] | null; +} + +export function buildHandoff(config: ConnectAppConfig, rules: ConnectorRule[]): ConnectHandoff { + return { v: 1, app: clientFields(config), ask: rules.length > 0 ? rules : null }; +} + +/** The handoff as the link's fragment carries it: base64url JSON, so it + * never reaches a server log. */ +export function encodeHandoff(handoff: ConnectHandoff): string { + return Buffer.from(JSON.stringify(handoff), 'utf8').toString('base64url'); +} + /** `.env.local` when the project has one (Next.js), else `.env`. */ export function defaultEnvFile(cwd: string): string { const local = path.join(cwd, '.env.local'); diff --git a/src/lib/connect-callback.test.ts b/src/lib/connect-callback.test.ts new file mode 100644 index 0000000..6eac07d --- /dev/null +++ b/src/lib/connect-callback.test.ts @@ -0,0 +1,53 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseConnectCallback, startConnectCallbackServer } from './connect-callback.js'; + +const at = (query: string, path = '/callback') => new URL(`http://localhost:1${path}?${query}`); + +describe('parseConnectCallback', () => { + it('reads an approved app: outcome, client id and ask id', () => { + assert.deepEqual(parseConnectCallback(at('state=s&outcome=created&clientId=f305b9fc&permissionSetId=ps-1'), 's'), { + status: 200, + outcome: { kind: 'done', outcome: 'created', clientId: 'f305b9fc', permissionSetId: 'ps-1' }, + }); + assert.deepEqual(parseConnectCallback(at('state=s&outcome=updated&clientId=f305b9fc'), 's').outcome, { + kind: 'done', + outcome: 'updated', + clientId: 'f305b9fc', + permissionSetId: null, + }); + }); + + it('refuses a wrong state, another path, and ids that are not plain ids', () => { + assert.equal(parseConnectCallback(at('state=x&outcome=created&clientId=a'), 's').status, 403); + assert.equal(parseConnectCallback(at('state=s', '/other'), 's').status, 404); + assert.equal(parseConnectCallback(at('state=s&outcome=created&clientId=a%0AONE_X%3D1'), 's').status, 400); + assert.equal(parseConnectCallback(at('state=s&outcome=created&clientId=a&permissionSetId=b%20c'), 's').status, 400); + assert.equal(parseConnectCallback(at('state=s&outcome=deleted&clientId=a'), 's').status, 400); + }); + + it('reports a cancel and a failure', () => { + assert.deepEqual(parseConnectCallback(at('state=s&error=cancelled'), 's').outcome, { kind: 'cancelled' }); + assert.deepEqual(parseConnectCallback(at('state=s&error=No%20access'), 's').outcome, { + kind: 'failed', + reason: 'No access', + }); + }); +}); + +describe('startConnectCallbackServer', () => { + it('sends the browser to the summary and hands the outcome to the command', async () => { + const { server, port, result } = await startConnectCallbackServer('s', (done) => `https://app.test/cli/connect/${done.clientId}`); + try { + const response = await fetch( + `http://127.0.0.1:${port}/callback?state=s&outcome=created&clientId=f305b9fc&permissionSetId=ps-1`, + { redirect: 'manual' } + ); + assert.equal(response.status, 302); + assert.equal(response.headers.get('location'), 'https://app.test/cli/connect/f305b9fc'); + assert.deepEqual(await result, { kind: 'done', outcome: 'created', clientId: 'f305b9fc', permissionSetId: 'ps-1' }); + } finally { + server.close(); + } + }); +}); diff --git a/src/lib/connect-callback.ts b/src/lib/connect-callback.ts new file mode 100644 index 0000000..1a7b384 --- /dev/null +++ b/src/lib/connect-callback.ts @@ -0,0 +1,128 @@ +import http from 'node:http'; + +const PORT_RANGE_START = 49152; +const PORT_RANGE_END = 65535; +const MAX_PORT_ATTEMPTS = 5; +const MAX_ID_LENGTH = 200; +const MAX_ERROR_LENGTH = 200; +/** Catalog ids, UUIDs and client ids: nothing else is written to `.env`. */ +const ID_PATTERN = /^[A-Za-z0-9_:.-]+$/; + +/** What the setup page reported back once the person approved or left. */ +export type ConnectCallbackOutcome = + | { kind: 'done'; outcome: 'created' | 'updated' | 'unchanged'; clientId: string; permissionSetId: string | null } + | { kind: 'cancelled' } + | { kind: 'failed'; reason: string }; + +/** The page the browser lands on when the CLI has nothing to send it back + * to. Copy is fixed, never user input. */ +function statusPage(title: string, body: string): string { + return ` +One CLI + +
+

${title}

+

${body}

+
`; +} +const CANCELLED_HTML = statusPage('Nothing was changed', 'You can close this tab and run one connect create again.'); +const FAILED_HTML = statusPage("The app wasn't set up", 'Return to your terminal for details.'); + +function cleanId(value: string | null): string | undefined { + if (!value) return undefined; + const trimmed = value.trim(); + return trimmed.length <= MAX_ID_LENGTH && ID_PATTERN.test(trimmed) ? trimmed : undefined; +} + +function cleanText(value: string | null): string | undefined { + if (value === null) return undefined; + const cleaned = value.replace(/[\u0000-\u001f\u007f-\u009f]/g, '').trim(); + return cleaned ? Array.from(cleaned).slice(0, MAX_ERROR_LENGTH).join('') : undefined; +} + +/** + * Reads one `/callback` request from the setup page. Pure, so the server + * stays a thin shell around it: a wrong or missing state is refused, ids + * that aren't plain ids are refused, and anything else is a failure the + * terminal should report. + */ +export function parseConnectCallback( + url: URL, + expectedState: string +): { status: number; outcome?: ConnectCallbackOutcome } { + if (url.pathname !== '/callback') return { status: 404 }; + const state = url.searchParams.get('state'); + if (!state) return { status: 400 }; + if (state !== expectedState) return { status: 403 }; + + const error = cleanText(url.searchParams.get('error')); + if (error === 'cancelled') return { status: 200, outcome: { kind: 'cancelled' } }; + if (error) return { status: 200, outcome: { kind: 'failed', reason: error } }; + + const outcome = url.searchParams.get('outcome'); + const clientId = cleanId(url.searchParams.get('clientId')); + const rawSet = url.searchParams.get('permissionSetId'); + const permissionSetId = rawSet ? cleanId(rawSet) : null; + if ( + (outcome !== 'created' && outcome !== 'updated' && outcome !== 'unchanged') || + !clientId || + permissionSetId === undefined + ) { + return { status: 400 }; + } + return { status: 200, outcome: { kind: 'done', outcome, clientId, permissionSetId } }; +} + +function randomPort(): number { + return PORT_RANGE_START + Math.floor(Math.random() * (PORT_RANGE_END - PORT_RANGE_START)); +} + +/** + * Listens on 127.0.0.1 for the setup page's one callback, as `one login` + * does. On success the browser is sent to `doneUrl(outcome)` (the page's + * own summary); a cancel or a failure gets a short page of its own. + */ +export function startConnectCallbackServer( + expectedState: string, + doneUrl: (outcome: Extract) => string +): Promise<{ server: http.Server; port: number; result: Promise }> { + return new Promise((resolveSetup, rejectSetup) => { + let attempts = 0; + let resolveResult: (value: ConnectCallbackOutcome) => void; + const result = new Promise((res) => { + resolveResult = res; + }); + + function tryListen() { + const port = randomPort(); + attempts++; + const server = http.createServer((req, res) => { + const { status, outcome } = parseConnectCallback(new URL(req.url || '/', `http://localhost:${port}`), expectedState); + if (!outcome) { + res.writeHead(status, { 'Content-Type': 'text/plain' }); + res.end(status === 403 ? 'State mismatch' : status === 404 ? 'Not found' : 'Missing required parameters'); + return; + } + if (outcome.kind === 'done') { + res.writeHead(302, { Location: doneUrl(outcome) }); + res.end(); + } else { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(outcome.kind === 'cancelled' ? CANCELLED_HTML : FAILED_HTML); + } + resolveResult(outcome); + }); + + server.on('error', (err: NodeJS.ErrnoException) => { + if ((err.code === 'EADDRINUSE' || err.code === 'EACCES') && attempts < MAX_PORT_ATTEMPTS) { + tryListen(); + return; + } + rejectSetup(err); + }); + server.listen(port, '127.0.0.1', () => resolveSetup({ server, port, result })); + } + + tryListen(); + }); +} diff --git a/src/lib/guide-content.ts b/src/lib/guide-content.ts index 632b34d..0cc7dbc 100644 --- a/src/lib/guide-content.ts +++ b/src/lib/guide-content.ts @@ -114,7 +114,7 @@ one --agent relay deliveries --endpoint-id # Check delivery - Use \`actions find\` (and \`actions load\` for more) to learn both the incoming payload shape AND the destination API shape before building templates ### One Connect apps — created from \`one.connect.ts\` -\`one connect create\` creates (or, once .env holds its \`ONE_CLIENT_ID\`, updates) a One Connect app from \`one.connect.ts\`, typed by \`defineConnectApp\` from \`@withone/connect/config\`; install \`@withone/connect\` (0.19.0 or later) in the project first. It writes \`ONE_CLIENT_ID\`, \`ONE_PERMISSION_SET\` and \`ONE_REDIRECT_URI\` to the env file and opens a setup page in the browser, where the human makes and copies the client secret and connect key; the CLI never receives them. \`one connect types\` writes \`one-connect-env.d.ts\` so tool slugs and action names autocomplete. Each tool's \`access\` is \`read-only\`, \`read-write\`, \`full-access\`, or \`custom\` with \`actions\` (names, or ids from \`actions find\`). +\`one connect create\` reads \`one.connect.ts\`, typed by \`defineConnectApp\` from \`@withone/connect/config\` (install \`@withone/connect\` 0.19.0 or later in the project first), and opens a setup page where the human reviews the app (or, once .env holds its \`ONE_CLIENT_ID\`, the changes) and approves it with their dashboard session; an API key never creates or changes a Connect app. A new app's client secret and connect key are made and shown there once; the CLI never receives them. It waits up to 10 minutes, then writes \`ONE_CLIENT_ID\`, \`ONE_PERMISSION_SET\` and \`ONE_REDIRECT_URI\` to the env file. \`one connect types\` writes \`one-connect-env.d.ts\` so tool slugs and action names autocomplete. Each tool's \`access\` is \`read-only\`, \`read-write\`, \`full-access\`, or \`custom\` with \`actions\` (names, or ids from \`actions find\`). ### 4. Memory + Sync — Unified store with hybrid FTS + semantic search One ships a local memory store (a real Postgres process bootstrapped on demand via the bundled \`embedded-postgres\` plugin, with a \`postgres\` plugin for remote/self-hosted) that backs both user-authored notes (\`one mem add\`) and synced platform data (\`one sync run\`). Auto-initializes on first use — no separate install step. Run \`one guide memory\` and \`one guide sync\` for the full references. diff --git a/src/lib/types.ts b/src/lib/types.ts index c5d38a7..037fb02 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -186,19 +186,6 @@ export interface AvailableAction { method: string; } -/** A Connect app as `/v1/oauth-clients` answers an API key: never a secret. */ -export interface ConnectAppView { - id: string; - clientId: string; - name: string; - redirectUris: string[]; -} - -export interface ConnectPermissionSetView { - id: string; - name: string; -} - export interface IoSchemaGroup { required?: string[]; properties?: Record; From e0db4df559d28a09359ae4bdd64d2a99ed8972a2 Mon Sep 17 00:00:00 2001 From: Krish Parekh Date: Wed, 28 Oct 2026 21:44:00 +0530 Subject: [PATCH 7/9] docs(connect): a re-run approves the app's new version Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- skills/one/SKILL.md | 2 +- src/cli.ts | 4 ++-- src/commands/connect.ts | 2 +- src/lib/guide-content.ts | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index d65a559..db192cd 100644 --- a/README.md +++ b/README.md @@ -536,7 +536,7 @@ one connect types # writes one-connect-env.d.ts: tool slugs and action names one connect create # approve the app in the browser; re-run to review and apply changes ``` -`one connect create` opens a setup page in your signed-in browser, where you review the app (or, on a re-run, the changes) and approve it; nothing is created or changed with your API key. A new app's client secret and connect key are made there and shown once, so they never pass through the terminal. Once you approve, the CLI writes `ONE_CLIENT_ID`, `ONE_PERMISSION_SET` and `ONE_REDIRECT_URI` to `.env.local` (or `.env`). Each tool in `ask` takes `access`: `read-only`, `read-write`, `full-access`, or `custom` with `actions`, named as `one connect types` wrote them or by id from `one actions search`. Options: `--config `, `--env-file `, `--no-open`. Install `@withone/connect` (0.19.0 or later) in the project first. A `.ts` config needs Node 22.18 or later; on older Node use `one.connect.mjs`. +`one connect create` opens a setup page in your signed-in browser, where you review the app (or, on a re-run, its new version) and approve it; nothing is created or changed with your API key. A new app's client secret and connect key are made there and shown once, so they never pass through the terminal. Once you approve, the CLI writes `ONE_CLIENT_ID`, `ONE_PERMISSION_SET` and `ONE_REDIRECT_URI` to `.env.local` (or `.env`). Each tool in `ask` takes `access`: `read-only`, `read-write`, `full-access`, or `custom` with `actions`, named as `one connect types` wrote them or by id from `one actions search`. Options: `--config `, `--env-file `, `--no-open`. Install `@withone/connect` (0.19.0 or later) in the project first. A `.ts` config needs Node 22.18 or later; on older Node use `one.connect.mjs`. ### `one guide [topic]` diff --git a/skills/one/SKILL.md b/skills/one/SKILL.md index bc9f4a8..08468ef 100644 --- a/skills/one/SKILL.md +++ b/skills/one/SKILL.md @@ -380,7 +380,7 @@ one --agent connect types # tool slugs and action names autocomplete in one one --agent connect create # the human approves it in the browser; then writes ONE_CLIENT_ID, ONE_PERMISSION_SET, ONE_REDIRECT_URI to .env ``` -It opens a setup page in the human's browser and waits up to 10 minutes, so run it in the background or with a long timeout; the link is printed to stderr at once, so pass it on if no browser opened. The human reviews the app (or, on a re-run, the changes) and approves it with their dashboard session: an API key never creates or changes a Connect app. For a new app the page also makes `ONE_CLIENT_SECRET` and `ONE_CONNECT_KEY` and shows them once; tell the human to copy them into the env file the result names. The CLI never receives either secret. Run `connect create` again after editing the file to update the same app. +It opens a setup page in the human's browser and waits up to 10 minutes, so run it in the background or with a long timeout; the link is printed to stderr at once, so pass it on if no browser opened. The human reviews the app (or, on a re-run, its new version) and approves it with their dashboard session: an API key never creates or changes a Connect app. For a new app the page also makes `ONE_CLIENT_SECRET` and `ONE_CONNECT_KEY` and shows them once; tell the human to copy them into the env file the result names. The CLI never receives either secret. Run `connect create` again after editing the file to update the same app. ## Adding New Connections diff --git a/src/cli.ts b/src/cli.ts index a85d2e1..3081c16 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -97,7 +97,7 @@ program one skills remove connect Remove it Connect apps (one.connect.ts, typed by @withone/connect/config): - one connect create Approve the app (or its changes) in the browser, then write .env + one connect create Approve the app (or its new version) in the browser, then write .env one connect types Write one-connect-env.d.ts so tool slugs and action names autocomplete Guide: @@ -671,7 +671,7 @@ const connectApp = program connectApp .command('create') - .description('Open the setup page to approve the app (or, once .env holds its ONE_CLIENT_ID, its changes); a new app gets its two secrets there. Then write .env') + .description('Open the setup page to approve the app (or, once .env holds its ONE_CLIENT_ID, its new version); a new app gets its two secrets there. Then write .env') .option('--config ', 'Config file (default: one.connect.ts, .mts, .mjs or .js in this directory)') .option('--env-file ', 'Env file to write (default: .env.local if it exists, else .env)') .option('--no-open', 'Print the setup page link instead of opening it') diff --git a/src/commands/connect.ts b/src/commands/connect.ts index 2b1222f..d137368 100644 --- a/src/commands/connect.ts +++ b/src/commands/connect.ts @@ -130,7 +130,7 @@ export async function connectCreateCommand(options: { config?: string; envFile?: const opening = options.open !== false; if (opening) await open(url).catch(() => undefined); - const verb = existingId ? 'Review the changes' : 'Create the app'; + const verb = existingId ? 'Approve the update' : 'Create the app'; if (output.isAgentMode()) { // The JSON result comes only once the person approves; the link goes // out now so an agent can hand it over if no browser opened. diff --git a/src/lib/guide-content.ts b/src/lib/guide-content.ts index 0cc7dbc..3e30c83 100644 --- a/src/lib/guide-content.ts +++ b/src/lib/guide-content.ts @@ -114,7 +114,7 @@ one --agent relay deliveries --endpoint-id # Check delivery - Use \`actions find\` (and \`actions load\` for more) to learn both the incoming payload shape AND the destination API shape before building templates ### One Connect apps — created from \`one.connect.ts\` -\`one connect create\` reads \`one.connect.ts\`, typed by \`defineConnectApp\` from \`@withone/connect/config\` (install \`@withone/connect\` 0.19.0 or later in the project first), and opens a setup page where the human reviews the app (or, once .env holds its \`ONE_CLIENT_ID\`, the changes) and approves it with their dashboard session; an API key never creates or changes a Connect app. A new app's client secret and connect key are made and shown there once; the CLI never receives them. It waits up to 10 minutes, then writes \`ONE_CLIENT_ID\`, \`ONE_PERMISSION_SET\` and \`ONE_REDIRECT_URI\` to the env file. \`one connect types\` writes \`one-connect-env.d.ts\` so tool slugs and action names autocomplete. Each tool's \`access\` is \`read-only\`, \`read-write\`, \`full-access\`, or \`custom\` with \`actions\` (names, or ids from \`actions find\`). +\`one connect create\` reads \`one.connect.ts\`, typed by \`defineConnectApp\` from \`@withone/connect/config\` (install \`@withone/connect\` 0.19.0 or later in the project first), and opens a setup page where the human reviews the app (or, once .env holds its \`ONE_CLIENT_ID\`, its new version) and approves it with their dashboard session; an API key never creates or changes a Connect app. A new app's client secret and connect key are made and shown there once; the CLI never receives them. It waits up to 10 minutes, then writes \`ONE_CLIENT_ID\`, \`ONE_PERMISSION_SET\` and \`ONE_REDIRECT_URI\` to the env file. \`one connect types\` writes \`one-connect-env.d.ts\` so tool slugs and action names autocomplete. Each tool's \`access\` is \`read-only\`, \`read-write\`, \`full-access\`, or \`custom\` with \`actions\` (names, or ids from \`actions find\`). ### 4. Memory + Sync — Unified store with hybrid FTS + semantic search One ships a local memory store (a real Postgres process bootstrapped on demand via the bundled \`embedded-postgres\` plugin, with a \`postgres\` plugin for remote/self-hosted) that backs both user-authored notes (\`one mem add\`) and synced platform data (\`one sync run\`). Auto-initializes on first use — no separate install step. Run \`one guide memory\` and \`one guide sync\` for the full references. From 1170edbeb05a2835065675657a0037afaba4cf12 Mon Sep 17 00:00:00 2001 From: Krish Parekh Date: Wed, 28 Oct 2026 22:04:18 +0530 Subject: [PATCH 8/9] feat(connect): deflate the setup link's config, guard its size, fix the actions hint The handoff is deflated before base64url (the page inflates it with the browser's DecompressionStream), roughly halving long action lists. A config over 32,000 characters stops with a message instead of opening a link a terminal can't hand over, and on Windows a link too long for PowerShell is printed to open by hand. The unknown-action hint and README now name `one actions find`; `one actions search` no longer exists. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/commands/connect.ts | 21 +++++++++++++++++---- src/lib/browser.test.ts | 4 ++-- src/lib/browser.ts | 2 +- src/lib/connect-app.test.ts | 13 +++++++++++-- src/lib/connect-app.ts | 19 ++++++++++++++++--- 6 files changed, 48 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index db192cd..be1e5ac 100644 --- a/README.md +++ b/README.md @@ -536,7 +536,7 @@ one connect types # writes one-connect-env.d.ts: tool slugs and action names one connect create # approve the app in the browser; re-run to review and apply changes ``` -`one connect create` opens a setup page in your signed-in browser, where you review the app (or, on a re-run, its new version) and approve it; nothing is created or changed with your API key. A new app's client secret and connect key are made there and shown once, so they never pass through the terminal. Once you approve, the CLI writes `ONE_CLIENT_ID`, `ONE_PERMISSION_SET` and `ONE_REDIRECT_URI` to `.env.local` (or `.env`). Each tool in `ask` takes `access`: `read-only`, `read-write`, `full-access`, or `custom` with `actions`, named as `one connect types` wrote them or by id from `one actions search`. Options: `--config `, `--env-file `, `--no-open`. Install `@withone/connect` (0.19.0 or later) in the project first. A `.ts` config needs Node 22.18 or later; on older Node use `one.connect.mjs`. +`one connect create` opens a setup page in your signed-in browser, where you review the app (or, on a re-run, its new version) and approve it; nothing is created or changed with your API key. A new app's client secret and connect key are made there and shown once, so they never pass through the terminal. Once you approve, the CLI writes `ONE_CLIENT_ID`, `ONE_PERMISSION_SET` and `ONE_REDIRECT_URI` to `.env.local` (or `.env`). Each tool in `ask` takes `access`: `read-only`, `read-write`, `full-access`, or `custom` with `actions`, named as `one connect types` wrote them or by id from `one actions find "" --no-knowledge`. Options: `--config `, `--env-file `, `--no-open`. Install `@withone/connect` (0.19.0 or later) in the project first. A `.ts` config needs Node 22.18 or later; on older Node use `one.connect.mjs`. ### `one guide [topic]` diff --git a/src/commands/connect.ts b/src/commands/connect.ts index d137368..937403b 100644 --- a/src/commands/connect.ts +++ b/src/commands/connect.ts @@ -14,6 +14,8 @@ import { buildHandoff, connectorRule, encodeHandoff, + MAX_HANDOFF_LENGTH, + MAX_WINDOWS_OPEN_LENGTH, defaultEnvFile, findConfigFile, loadConfigFile, @@ -84,7 +86,7 @@ async function buildRules(api: OneApi, config: ConnectAppConfig): Promise"\` for an id.` + `Run \`one connect types\` for the names, or \`one actions find ${slug} "" --no-knowledge\` for an id.` ); } rules.push(connectorRule(definitionId, ask, resolved.ids)); @@ -114,6 +116,14 @@ export async function connectCreateCommand(options: { config?: string; envFile?: envFile: path.basename(envFile), }; + const handoff = encodeHandoff(buildHandoff(config, rules)); + if (handoff.length > MAX_HANDOFF_LENGTH) { + output.error( + `${path.basename(file)} is too big to hand to the browser (${Math.ceil(handoff.length / 1024)} KB compressed, ` + + `limit ${Math.floor(MAX_HANDOFF_LENGTH / 1024)} KB). Give tools with long custom lists an access level instead.` + ); + } + const state = crypto.randomBytes(16).toString('hex'); const started = await startConnectCallbackServer(state, (done) => getConnectDoneUrl(done.clientId, done.outcome, scope) @@ -125,10 +135,13 @@ export async function connectCreateCommand(options: { config?: string; envFile?: permissionSetId: readEnvValue(envText, 'ONE_PERMISSION_SET') || undefined, port: started.port, state, - handoff: encodeHandoff(buildHandoff(config, rules)), + handoff, }); - const opening = options.open !== false; + // Windows opens links through PowerShell, which can't take a very long + // one; the person opens the printed link instead. + const tooLongToOpen = process.platform === 'win32' && url.length > MAX_WINDOWS_OPEN_LENGTH; + const opening = options.open !== false && !tooLongToOpen; if (opening) await open(url).catch(() => undefined); const verb = existingId ? 'Approve the update' : 'Create the app'; if (output.isAgentMode()) { @@ -136,7 +149,7 @@ export async function connectCreateCommand(options: { config?: string; envFile?: // out now so an agent can hand it over if no browser opened. process.stderr.write(`${verb} in the browser: ${url}\n`); } else { - spinner.stop(`${verb} in your browser`); + spinner.stop(opening ? `${verb} in your browser` : `${verb}: open this link in your browser`); console.log(` ${pc.underline(url)}`); console.log(); spinner.start('Waiting for you to approve it...'); diff --git a/src/lib/browser.test.ts b/src/lib/browser.test.ts index 8020376..d5875c3 100644 --- a/src/lib/browser.test.ts +++ b/src/lib/browser.test.ts @@ -70,14 +70,14 @@ describe('connect setup links', () => { it('opens /cli/connect/new for a new app, the config in the fragment', () => { assert.equal( getConnectSetupUrl({ orgId: 'org-1', envFile: '.env.local', port: 51234, state: 'abc', handoff: 'eyJ2IjoxfQ' }), - 'https://app.withone.ai/cli/connect/new?orgId=org-1&env=.env.local&port=51234&state=abc#c=eyJ2IjoxfQ' + 'https://app.withone.ai/cli/connect/new?orgId=org-1&env=.env.local&port=51234&state=abc#z=eyJ2IjoxfQ' ); }); it('opens the app itself for an update, naming its ask', () => { assert.equal( getConnectSetupUrl({ clientId: 'f305b9fc', permissionSetId: 'ps-1', port: 1, state: 's', handoff: 'x' }), - 'https://app.withone.ai/cli/connect/f305b9fc?set=ps-1&port=1&state=s#c=x' + 'https://app.withone.ai/cli/connect/f305b9fc?set=ps-1&port=1&state=s#z=x' ); }); diff --git a/src/lib/browser.ts b/src/lib/browser.ts index 046a95a..7f153dc 100644 --- a/src/lib/browser.ts +++ b/src/lib/browser.ts @@ -74,7 +74,7 @@ export function getConnectSetupUrl( ['port', String(opts.port)], ['state', opts.state], ]); - return `${oneAppUrl()}/cli/connect/${target}${qs}#c=${opts.handoff}`; + return `${oneAppUrl()}/cli/connect/${target}${qs}#z=${opts.handoff}`; } /** Where the CLI sends the browser once it has written `.env`: the setup diff --git a/src/lib/connect-app.test.ts b/src/lib/connect-app.test.ts index e42e40c..9835d2c 100644 --- a/src/lib/connect-app.test.ts +++ b/src/lib/connect-app.test.ts @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import zlib from 'node:zlib'; import { actionName, actionNames, @@ -206,10 +207,18 @@ describe('handoff', () => { assert.equal(buildHandoff(config, []).ask, null); }); - it('encodes as base64url JSON that decodes back', () => { + it('encodes as deflated base64url JSON that inflates back', () => { const handoff = buildHandoff(config, []); const encoded = encodeHandoff(handoff); assert.match(encoded, /^[A-Za-z0-9_-]+$/); - assert.deepEqual(JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')), handoff); + const json = zlib.inflateRawSync(Buffer.from(encoded, 'base64url')).toString('utf8'); + assert.deepEqual(JSON.parse(json), handoff); + }); + + it('roughly halves a long list of action ids', () => { + const ids = Array.from({ length: 200 }, (_, i) => `conn_mod_def::GJ7Km${i}Q::C4xVtTCm${i}TjeKrcf-4UV9Iw`); + const handoff = buildHandoff(config, [connectorRule(7, { access: 'custom', actions: ids }, ids)]); + const plain = Buffer.from(JSON.stringify(handoff)).toString('base64url').length; + assert.ok(encodeHandoff(handoff).length < plain / 2); }); }); diff --git a/src/lib/connect-app.ts b/src/lib/connect-app.ts index ff363b1..f337087 100644 --- a/src/lib/connect-app.ts +++ b/src/lib/connect-app.ts @@ -1,5 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; +import zlib from 'node:zlib'; import { pathToFileURL } from 'node:url'; /** @@ -216,12 +217,24 @@ export function buildHandoff(config: ConnectAppConfig, rules: ConnectorRule[]): return { v: 1, app: clientFields(config), ask: rules.length > 0 ? rules : null }; } -/** The handoff as the link's fragment carries it: base64url JSON, so it - * never reaches a server log. */ +/** + * The handoff as the link's fragment carries it: JSON, deflated (the + * browser inflates it with its built-in DecompressionStream), then + * base64url. The fragment never reaches a server, so size is bounded only + * by what can open a URL; deflate roughly halves a long list of action ids. + */ export function encodeHandoff(handoff: ConnectHandoff): string { - return Buffer.from(JSON.stringify(handoff), 'utf8').toString('base64url'); + return zlib.deflateRawSync(Buffer.from(JSON.stringify(handoff), 'utf8'), { level: 9 }).toString('base64url'); } +/** Past this the link is too long for a terminal to hand over reliably; + * far beyond any real app (about 30 custom tools of 30 actions each). */ +export const MAX_HANDOFF_LENGTH = 32_000; + +/** Windows opens links through PowerShell, whose command line holds a + * URL of about 12,000 characters; past this the CLI prints it instead. */ +export const MAX_WINDOWS_OPEN_LENGTH = 11_000; + /** `.env.local` when the project has one (Next.js), else `.env`. */ export function defaultEnvFile(cwd: string): string { const local = path.join(cwd, '.env.local'); From 2d238d2c09540a79c80807ae4fbfd94567e81bb2 Mon Sep 17 00:00:00 2001 From: Krish Parekh Date: Wed, 28 Oct 2026 22:06:26 +0530 Subject: [PATCH 9/9] fix(connect): match the SDK upgrade error on Windows paths, split its test per project Windows writes the package path in ERR_PACKAGE_PATH_NOT_EXPORTED as @withone\connect, so the upgrade hint never showed there; the check now takes either separator. The test loaded one config file twice, which Node 22 answers from its cache of the first failed import; each case now gets its own project. Co-Authored-By: Claude Opus 5.5 --- src/lib/connect-app.test.ts | 21 ++++++++++++++++----- src/lib/connect-app.ts | 3 ++- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/lib/connect-app.test.ts b/src/lib/connect-app.test.ts index 9835d2c..b6e7c53 100644 --- a/src/lib/connect-app.test.ts +++ b/src/lib/connect-app.test.ts @@ -138,6 +138,9 @@ describe('upsertEnv', () => { }); }); +const CONFIG_IMPORTING_SDK = + 'import { defineConnectApp } from "@withone/connect/config";\nexport default defineConnectApp({ name: "Mastra" });\n'; + describe('config files', () => { it('finds and loads an .mjs config', async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'one-connect-')); @@ -158,22 +161,30 @@ describe('config files', () => { } }); - it('says to install the SDK when the project has none, or one without /config', async () => { + // Two projects, not one: Node 22 caches a failed import of the same + // file, so a second load in one project would replay the first error. + it('says to install the SDK when the project has none', async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'one-connect-')); try { const file = path.join(dir, 'one.connect.mjs'); - fs.writeFileSync( - file, - 'import { defineConnectApp } from "@withone/connect/config";\nexport default defineConnectApp({ name: "Mastra" });\n' - ); + fs.writeFileSync(file, CONFIG_IMPORTING_SDK); await assert.rejects(loadConfigFile(file), /isn't installed in this project\. Run `npm i @withone\/connect` first/); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + it('says to upgrade an SDK older than 0.19.0, which has no /config', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'one-connect-')); + try { const sdk = path.join(dir, 'node_modules', '@withone', 'connect'); fs.mkdirSync(sdk, { recursive: true }); fs.writeFileSync( path.join(sdk, 'package.json'), JSON.stringify({ name: '@withone/connect', version: '0.18.0', exports: { '.': './index.js' } }) ); + const file = path.join(dir, 'one.connect.mjs'); + fs.writeFileSync(file, CONFIG_IMPORTING_SDK); await assert.rejects(loadConfigFile(file), /older than 0\.19\.0.*npm i @withone\/connect@latest/); } finally { fs.rmSync(dir, { recursive: true, force: true }); diff --git a/src/lib/connect-app.ts b/src/lib/connect-app.ts index f337087..d57f8fe 100644 --- a/src/lib/connect-app.ts +++ b/src/lib/connect-app.ts @@ -71,7 +71,8 @@ export async function loadConfigFile(file: string): Promise { `Run \`npm i @withone/connect\` first.` ); } - if (code === 'ERR_PACKAGE_PATH_NOT_EXPORTED' && message.includes('@withone/connect')) { + // The path in this message uses the platform's separator: @withone\connect on Windows. + if (code === 'ERR_PACKAGE_PATH_NOT_EXPORTED' && /@withone[\\/]connect/.test(message)) { throw new Error( `this project's @withone/connect is older than 0.19.0, which added @withone/connect/config. ` + `Run \`npm i @withone/connect@latest\` first.`