diff --git a/README.md b/README.md index 8ad5559..be1e5ac 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 # 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 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]` 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..08468ef 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, 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 +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, 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 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..3081c16 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 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: 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('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') + .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..937403b --- /dev/null +++ b/src/commands/connect.ts @@ -0,0 +1,242 @@ +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 { 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, + buildHandoff, + connectorRule, + encodeHandoff, + MAX_HANDOFF_LENGTH, + MAX_WINDOWS_OPEN_LENGTH, + defaultEnvFile, + findConfigFile, + loadConfigFile, + readEnvValue, + renderTypes, + resolveActions, + upsertEnv, + validateConfig, + type ConnectAppConfig, + type ConnectorRule, +} from '../lib/connect-app.js'; +import * as output from '../lib/output.js'; + +/** 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(); + 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 find ${slug} "" --no-knowledge\` 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 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 whoami = await ensureWhoAmI(api); + const scope = { + orgId: whoami?.organization?.id, + projectId: whoami?.project?.id, + 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) + ); + server = started.server; + const url = getConnectSetupUrl({ + ...scope, + clientId: existingId, + permissionSetId: readEnvValue(envText, 'ONE_PERMISSION_SET') || undefined, + port: started.port, + state, + handoff, + }); + + // 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()) { + // 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(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...'); + } + + 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: outcome.clientId, + ONE_REDIRECT_URI: config.redirectUris[0], + ...(outcome.permissionSetId ? { ONE_PERMISSION_SET: outcome.permissionSetId } : {}), + ...(created ? { ONE_CLIENT_SECRET: null, ONE_CONNECT_KEY: null } : {}), + }) + ); + + const result = { + outcome: outcome.outcome, + clientId: outcome.clientId, + permissionSetId: outcome.permissionSetId, + redirectUris: config.redirectUris, + envFile: path.relative(cwd, envFile) || envFile, + }; + if (output.isAgentMode()) { + output.json(result); + return; + } + + 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:')} ${outcome.clientId}`); + if (outcome.permissionSetId) console.log(` ${pc.dim('Permission set:')} ${outcome.permissionSetId}`); + console.log(` ${pc.dim('Wrote')} ${result.envFile}`); + 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(); + } +} + +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..0c24af3 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -593,6 +593,23 @@ export class OneApi { } } + /** 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..d5875c3 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, getConnectDoneUrl, getConnectSetupUrl, getConnectionUrl, oneAppUrl } from './browser.js'; describe('oneAppUrl', () => { let saved: string | undefined; @@ -58,3 +58,33 @@ describe('getCliAuthUrl', () => { assert.equal(url.searchParams.get('user'), null); }); }); + +describe('connect setup links', () => { + 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 /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#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#z=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' + ); + }); +}); diff --git a/src/lib/browser.ts b/src/lib/browser.ts index 5cbd941..7f153dc 100644 --- a/src/lib/browser.ts +++ b/src/lib/browser.ts @@ -33,6 +33,60 @@ 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`. 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 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}#z=${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, + outcome: 'created' | 'updated' | 'unchanged', + scope: ConnectSetupScope +): string { + return `${oneAppUrl()}/cli/connect/${encodeURIComponent(clientId)}${connectSetupParams(scope, [['outcome', outcome]])}`; +} + 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..b6e7c53 --- /dev/null +++ b/src/lib/connect-app.test.ts @@ -0,0 +1,235 @@ +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 zlib from 'node:zlib'; +import { + actionName, + actionNames, + buildHandoff, + encodeHandoff, + 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'); + }); +}); + +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-')); + 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 }); + } + }); + + // 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, 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 }); + } + }); +}); + +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";/); + }); +}); + +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 deflated base64url JSON that inflates back', () => { + const handoff = buildHandoff(config, []); + const encoded = encodeHandoff(handoff); + assert.match(encoded, /^[A-Za-z0-9_-]+$/); + 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 new file mode 100644 index 0000000..d57f8fe --- /dev/null +++ b/src/lib/connect-app.ts @@ -0,0 +1,293 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import zlib from 'node:zlib'; +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, 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.` + ); + } + // 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.` + ); + } + 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; +} + +/** + * 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: 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 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'); + 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/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 8321784..3e30c83 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\` 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.