diff --git a/README.md b/README.md index 556a114..3d75e5e 100644 --- a/README.md +++ b/README.md @@ -290,6 +290,7 @@ available yet: the backend refuses them). Since 0.2.1, unknown commands and subc `unknown_command` before any credential lookup. Published 0.1.0 through 0.2.3 do not include `login`, `onboard`, `new`, `doctor` or `dev`. Browser login and the agent onboarding CLI live in the separate `chert` CLI from `@trychert/facetime`. +Unreleased: `chert-sdk onboard` for a Mac, see [setup](docs/SETUP.md#automated-onboarding-on-a-mac). ### Custom video sources (not available yet) diff --git a/docs/SETUP.md b/docs/SETUP.md index dddbf3f..6c9260b 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -16,11 +16,51 @@ Coding agents can read the same material from [`llms.txt`](https://github.com/ch | Migrate from another iMessage provider SDK | `chert-sdk adopt` (ships in `@trychert/sdk`) | The SDKs and the FaceTime agent CLI are separate packages with separate commands. -The SDK's `chert-sdk` CLI has no `login`, `onboard`, `new` or `dev` command. +The SDK's `chert-sdk` CLI has no `login`, `new` or `dev` command. On a Mac, +[`chert-sdk onboard`](#automated-onboarding-on-a-mac) gets you started without a browser. For Better Auth, see the [phone transport guide](BETTER_AUTH.md): source preview, not included in npm 0.2.3. Existing email auth stays enabled alongside phone. +## Automated onboarding on a Mac + +Not released yet: this needs the next `@trychert/sdk` and server support that has +not shipped. + +```sh +chert-sdk onboard +``` + +1. Reads the phone numbers signed in to Messages. With several, it asks which to + use, or all. It reads Messages' aliases only, so a number listed only in the + Apple ID's vetted aliases is not offered. +2. After you confirm, it sends a one-time code to Chert from your Messages app for + each number. The first run asks macOS for permission to control Messages. Chert + accepts a code only from the number it was issued for, so reading a number from + your Mac is never itself proof. +3. Saves the sign-in where the `chert` CLI reads it (`~/.chert`, or `CHERT_HOME`). +4. Sets up shared access and your trial, verifies the number for calls with one more + code, then requests the FaceTime demo. It waits in line if the line is busy. An + accepted request does not confirm audio or video worked. +5. In a project (a language manifest or `.git`), it prints the install command and a + prompt for your coding agent, and writes `CHERT_FACETIME_TOKEN` and + `CHERT_PROJECT_ID` to `.env.local` only if git ignores that file and it does not + already set them differently. It never edits source files. Elsewhere, it offers to + install the `chert` CLI from `@trychert/facetime`. + +Messages sends from the number under **Settings > iMessage > Start New Conversations +From**, and a script cannot choose it. A chosen number sent from another number does +not verify, and the command says so. Change that setting and rerun to add it. With +two numbers, the demo calls the first one verified. + +| Flag | Effect | +| --- | --- | +| `--numbers +A,+B` | Use these numbers, by value or list position, or `all`. They must be on this Mac. Counts as confirmation | +| `--yes` | Use every number without asking | +| `--json` | One JSON line per step with a stable `code`. Never prompts; without a choice it stops with `numbers_required` and masked numbers | +| `--skip-demo` | Stop before the demo call | +| `--no-wire` | Skip the project or CLI step | + ## 1. Install JavaScript / TypeScript (Node 22.12+, current Bun, or Deno 2): @@ -55,8 +95,8 @@ Credentials are per service and are not interchangeable. | `CHERT_PROJECT_ID` | FaceTime (required), iMessage (context only) | Your project UUID. The dashboard does not display it yet: install the agent CLI (`npm install -g @trychert/facetime@beta`), run `chert login`, then `chert whoami --json` and copy `project_id`. | Both credentials need an account with Chert. A browser is required once, to -sign in and create the key; nothing in this guide can create an account or a key -from the terminal. +sign in and create the key, unless you use `chert-sdk onboard` on a Mac, which +creates a CLI login (not a project API key) for FaceTime. A project API key works for lines, workers, calls and assistants within the scopes you give it. It is not a person, so it cannot read a signed-in identity: diff --git a/llms.txt b/llms.txt index d709e70..7af221c 100644 --- a/llms.txt +++ b/llms.txt @@ -13,8 +13,8 @@ Rules for coding agents: - FaceTime calls can carry audio and video (calls can also be audio-only). Video needs a line with `supports_video`, a FaceTime Video call, and an agent that publishes a camera track. - Direct audio transport (`media_transport: "direct"` with `media_url`/`media_token` on customer-managed calls, no LiveKit) is not available yet; the SDKs send it but the hosted backend does not accept it. See FaceTime notes. - Custom video (WHIP/WHEP), living services, screen sharing and call video composition are not available yet (PRs #17, #18, #25, #29); do not build on them. All four ship in the package (composition since 0.2.3), but the hosted backend does not serve them (`404 endpoint_unavailable`). -- Credentials need a browser once: a FaceTime project API key comes from the dashboard's Developer tab, the project UUID from `chert whoami --json`, and billing only works in the browser today. -- `chert-sdk` has no `login`, `onboard`, `new` or `dev` command; those belong to the `chert` CLI from `@trychert/facetime`. +- Without `chert-sdk onboard`, credentials need a browser once: a FaceTime project API key comes from the dashboard's Developer tab, the project UUID from `chert whoami --json`, and billing only works in the browser today. +- `chert-sdk onboard` (macOS, unreleased) signs in with the Mac's own number by sending a one-time code from Messages, sets up the trial and requests the FaceTime demo. In a project it prints a `wire_in` handoff with a prompt; elsewhere it sets up the `chert` CLI. With `--json` and no choice it stops with `numbers_required`: ask the user which numbers, then pass `--numbers`. `chert-sdk` has no `login`, `new` or `dev` command; those belong to the `chert` CLI from `@trychert/facetime`. ## Docs diff --git a/packages/unified/cli.test.ts b/packages/unified/cli.test.ts index c4b65c9..ba30377 100644 --- a/packages/unified/cli.test.ts +++ b/packages/unified/cli.test.ts @@ -63,7 +63,7 @@ test('CLI preserves a safe SDK error code without exposing server text or respon }); test('CLI reports unknown commands as such, even without credentials', async () => { - for (const command of [['login'], ['onboard'], ['facetim'], ['facetime', 'statsu'], ['facetime', 'lines', 'extra'], ['imessage', 'sned'], ['context', 'extra'], ['context', '--offline', 'extra'], ['context', '--bogus'], ['billing'], ['billing', 'refund'], ['billing', 'status', 'extra']]) { + for (const command of [['login'], ['facetim'], ['facetime', 'statsu'], ['facetime', 'lines', 'extra'], ['imessage', 'sned'], ['context', 'extra'], ['context', '--offline', 'extra'], ['context', '--bogus'], ['billing'], ['billing', 'refund'], ['billing', 'status', 'extra']]) { const env = { ...process.env }; delete env.CHERT_PROJECT_ID; const child = Bun.spawn(['node', resolve(import.meta.dir, '../../dist/unified/cli.js'), ...command], { env, stdout: 'pipe', stderr: 'pipe' }); expect(await child.exited).toBe(1); diff --git a/packages/unified/cli.ts b/packages/unified/cli.ts index 05cbd58..dd557d9 100644 --- a/packages/unified/cli.ts +++ b/packages/unified/cli.ts @@ -4,10 +4,12 @@ import { existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { main as adopt } from '../adopt/src/main.js'; import { SDK_VERSION } from '../adopt/src/providers.js'; +import { runOnboard } from './onboard.js'; const DOCS = 'https://github.com/cherthq/sdk/blob/main'; const help = `chert-sdk — unified Chert SDK CLI + chert-sdk onboard [--numbers +A,+B|1,2|all] [--yes] [--json] [--skip-demo] [--no-wire] chert-sdk imessage send --phone +COUNTRYNUMBER --body TEXT --idempotency-key KEY --confirm chert-sdk facetime status | lines | workers chert-sdk billing status (not available yet) @@ -16,6 +18,13 @@ const help = `chert-sdk — unified Chert SDK CLI chert-sdk adopt [path] [--write] [--install] [--json] chert-sdk context [--offline] +onboard (macOS) reads your number from Messages, signs you in by sending a one-time +code from your Messages app, sets up the trial, starts the FaceTime demo call, then +finishes for this directory: wiring instructions for your coding agent in a project, +the personal agent CLI elsewhere. Without --numbers or --yes it asks, and with --json +or no terminal it stops with numbers_required. Output with --json is one JSON line +per step. + context prints SDK, credential, account and documentation context for coding agents. It reads FaceTime lines, and identity with a CLI login (not a project API key), when CHERT_FACETIME_TOKEN and CHERT_PROJECT_ID are set; --offline skips @@ -34,8 +43,8 @@ Credentials by surface (service-specific, not interchangeable): iMessage defaults to https://console.trychert.com; CHERT_BASE_URL optionally overrides it. FaceTime defaults to https://facetime.trychert.com; CHERT_FACETIME_URL optionally overrides the origin. -FaceTime commands inspect existing access/lines; no calls, media relay, -concierge onboarding or automatic shared-line allocation is implemented. +FaceTime commands inspect existing access/lines; they place no calls and relay no +media. Only onboard allocates shared access and requests the demo call. The billing commands are not available yet: the backend refuses CLI credentials, so today billing only works from a signed-in browser session. Once available, buy charges the saved card and is refused while a subscription is live; when a @@ -56,6 +65,7 @@ async function main() { const [channel, command, ...args] = process.argv.slice(2); if (channel === 'adopt') { process.exitCode = adopt(process.argv.slice(3)); return; } if (!channel || process.argv.includes('--help') || process.argv.includes('-h')) { console.log(help); return; } + if (channel === 'onboard') { process.exitCode = await runOnboard(process.argv.slice(3)); return; } // Reject unknown commands before asking for credentials, so typos are not reported as missing configuration. const unknown = channel === 'context' ? (command !== undefined && command !== '--offline') || args.length > 0 : channel === 'imessage' ? command !== 'send' @@ -138,7 +148,7 @@ async function context(env: NodeJS.ProcessEnv, offline: boolean) { }, missing: ['CHERT_PROJECT_ID', 'CHERT_API_KEY', 'CHERT_FACETIME_TOKEN'].filter(name => !set(name)), account: { facetime: offline ? 'skipped' : facetimeAccount ?? 'not_configured', imessage: 'not_inspected' }, - commands: ['imessage send', 'facetime status', 'facetime lines', 'facetime workers', 'billing status (not available yet)', 'billing card (not available yet)', 'billing buy (not available yet)', 'billing cancel (not available yet)', 'adopt', 'context'], + commands: ['onboard', 'imessage send', 'facetime status', 'facetime lines', 'facetime workers', 'billing status (not available yet)', 'billing card (not available yet)', 'billing buy (not available yet)', 'billing cancel (not available yet)', 'adopt', 'context'], docs: { setup: `${DOCS}/docs/SETUP.md`, llms: `${DOCS}/llms.txt`, ...(existsSync(llmsFile) ? { llmsFile } : {}), readme: `${DOCS}/README.md`, compatibility: `${DOCS}/COMPATIBILITY.md` }, agentCli: { package: '@trychert/facetime', bin: 'chert', use: 'Browser login and putting a LiveKit voice or video agent on a FaceTime line' }, diff --git a/packages/unified/onboard.test.ts b/packages/unified/onboard.test.ts new file mode 100644 index 0000000..458cc23 --- /dev/null +++ b/packages/unified/onboard.test.ts @@ -0,0 +1,552 @@ +import { afterEach, beforeEach, expect, test } from 'bun:test'; +import { createHash } from 'node:crypto'; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { detectProject, e164, maskNumber, parseMessagesAliases, runOnboard, type OnboardDeps } from './onboard'; + +const A = '+15555550100', B = '+15555550199', LINE = '+15555550123', FOREIGN = '+15555550142'; +const TOKEN = `ftu_${'t'.repeat(43)}`, PROJECT = '11111111-1111-4111-8111-111111111111', SESSION = '22222222-2222-4222-8222-222222222222'; +const DEVICE = `fpl_${'d'.repeat(43)}`, CALL = '33333333-3333-4333-8333-333333333333'; +const CODE = '482913'; +const message = (number: string) => `CHERT-LOGIN-${number.slice(-4)}SECRET`; + +interface World { + aliases: unknown[]; home: string; cwd: string; out: string[]; err: string[]; sent: string[][]; calls: { path: string; body: unknown; headers: Headers; method: string }[]; + execFail: Record; proven?: boolean; prompts: string[]; answers: string[]; ignored: boolean; chertInstalled: boolean; npmPublished: boolean; installs: string[][]; + verified: string[]; unlinked: string[]; finishOnly: boolean; tokenPolls: number; session: Record; responses: Record; + platform: string; interactive: boolean; now: number; +} +let world: World; +let root: string; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'chert-onboard-')); + mkdirSync(join(root, 'home')); + mkdirSync(join(root, 'cwd')); + world = { aliases: [A], home: join(root, 'home'), cwd: join(root, 'cwd'), out: [], err: [], sent: [], calls: [], execFail: {}, prompts: [], answers: [], ignored: true, chertInstalled: true, npmPublished: true, installs: [], + verified: [A], unlinked: [], finishOnly: false, tokenPolls: 0, session: { status: 'awaiting_verification' }, responses: {}, platform: 'darwin', interactive: false, now: 1_000_000 }; +}); +afterEach(() => rmSync(root, { recursive: true, force: true })); + +function deps(): OnboardDeps { + return { + async exec(file, args) { + const fail = world.execFail[file]; + if (fail) throw Object.assign(new Error('failed'), fail); + if (file === 'plutil') return { stdout: JSON.stringify({ allAliases: world.aliases, selectedAliases: [] }) }; + if (file === 'osascript') { world.sent.push(args); return { stdout: '' }; } + if (file === 'git') { if (!world.ignored) throw new Error('not ignored'); return { stdout: '' }; } + if (file === 'chert') { if (!world.chertInstalled) throw new Error('missing'); return { stdout: '1' }; } + if (file === 'npm') { + if (args[0] === 'view') { if (!world.npmPublished) throw new Error('missing'); return { stdout: '1' }; } + world.installs.push(args); return { stdout: '' }; + } + throw new Error(`unexpected ${file}`); + }, + fetch: (async (input: URL, init: RequestInit) => { + const path = new URL(input).pathname, body = init.body ? JSON.parse(String(init.body)) : undefined; + world.calls.push({ path, body, headers: new Headers(init.headers), method: init.method ?? 'GET' }); + const canned = world.responses[path]; + if (canned) return Response.json(canned.body, { status: canned.status }); + const data = route(path, init.method ?? 'GET', body); + return Response.json({ data }, { status: 200 }); + }) as unknown as typeof fetch, + async prompt(question) { world.prompts.push(question); return world.answers.shift() ?? ''; }, + now: () => world.now, + async sleep(ms) { world.now += ms; }, + out: line => world.out.push(line), + err: line => world.err.push(line), + platform: world.platform, + interactive: world.interactive, + home: world.home, + cwd: world.cwd, + env: {}, + deviceName: 'Test Mac', + }; +} + +function route(path: string, method: string, body: { numbers?: string[]; finish?: boolean; recipient?: string }): unknown { + const view = () => ({ id: SESSION, line: { id: PROJECT, receiving_number: LINE }, ...world.session }); + if (path === '/api/v1/phone-logins') return { device_code: DEVICE, receiving_number: LINE, expires_in: 600, interval: 2, challenges: body.numbers!.map(number => ({ number, message: message(number) })) }; + if (path === '/api/v1/phone-logins/token') { + world.tokenPolls++; + if (world.finishOnly && !body.finish) return { status: 'pending', verified_count: world.verified.length }; + if (!world.verified.length) return { status: 'pending', verified_count: 0 }; + return { status: 'approved', access_token: TOKEN, project_id: PROJECT, verified_numbers: world.verified, unlinked_numbers: world.unlinked, device_name: 'Test Mac', expires_in: 86400 }; + } + if (path === '/api/v1/allowance') return { plan: 'trial', minutes_left: 10, nested: { secret: 'no' } }; + if (path === '/api/v1/onboarding') return view(); + if (path.endsWith('/verify')) { world.session = { status: 'ready' }; return world.proven ? { code: null, verified: true } : { code: CODE, recipient: body.recipient }; } + if (path.endsWith('/demo')) { world.session = { status: 'demo_running', demo_call_id: CALL }; return view(); } + if (method === 'GET') return view(); + throw new Error(`unexpected ${path}`); +} + +const records = () => world.out.map(line => JSON.parse(line) as { code: string; [key: string]: unknown }); +const failure = () => JSON.parse(world.err.at(-1)!) as { code: string; [key: string]: unknown }; +const run = (...args: string[]) => runOnboard(['--json', ...args], deps()); +const everything = () => [...world.out, ...world.err].join('\n'); +const credentials = () => join(world.home, '.chert', 'credentials.json'); + +test('masks numbers to their last four digits', () => { + expect(maskNumber(A)).toBe('•••0100'); +}); + +test('parses Messages aliases: numbers only, each once', () => { + expect(parseMessagesAliases(JSON.stringify({ allAliases: [A, ' ' + B, 'person@example.com', A, '+0123456', '15555550100', 7, null] }))).toEqual([A, B]); + expect(parseMessagesAliases('not json')).toEqual([]); + expect(parseMessagesAliases('{}')).toEqual([]); + expect(e164(' +15555550100 ')).toBe(A); + expect(e164('tel:+15555550100')).toBeNull(); +}); + +test('refuses to run off a Mac', async () => { + world.platform = 'linux'; + expect(await run('--yes')).toBe(1); + expect(failure().code).toBe('macos_required'); + expect(world.calls).toEqual([]); +}); + +test('reports no numbers when Messages has none or cannot be read', async () => { + world.aliases = ['person@example.com']; + expect(await run('--yes')).toBe(1); + expect(failure().code).toBe('no_phone_numbers'); + world.execFail.plutil = {}; + expect(await run('--yes')).toBe(1); + expect(failure().code).toBe('no_phone_numbers'); + expect(world.calls).toEqual([]); +}); + +test('a machine caller without a choice gets masked numbers and nothing is sent', async () => { + world.aliases = [A, B]; + expect(await run()).toBe(1); + expect(failure()).toMatchObject({ code: 'numbers_required', numbers: [{ index: 1, number: '•••0100' }, { index: 2, number: '•••0199' }] }); + expect(world.calls).toEqual([]); + expect(world.sent).toEqual([]); + expect(everything()).not.toContain(A); +}); + +test('a terminal without json still stops at a choice of numbers without a real terminal', async () => { + world.aliases = [A, B]; + expect(await runOnboard([], deps())).toBe(1); + expect(world.err.at(-1)).toContain('--numbers'); + expect(world.calls).toEqual([]); +}); + +test('a number not on this Mac is refused before any request', async () => { + expect(await run('--numbers', FOREIGN)).toBe(1); + expect(failure().code).toBe('number_not_on_this_mac'); + expect(await run('--numbers', '9')).toBe(1); + expect(failure().code).toBe('number_not_on_this_mac'); + expect(await run('--numbers', 'nonsense')).toBe(1); + expect(failure().code).toBe('invalid_numbers'); + expect(world.calls).toEqual([]); + expect(everything()).not.toContain(FOREIGN); +}); + +test('the concierge asks which number and sends straight away', async () => { + world.aliases = [A, B]; + world.interactive = true; + world.answers = ['2']; + expect(await runOnboard(['--skip-demo', '--no-wire'], deps())).toBe(1); + expect(world.out.join('\n')).toContain(B); + expect(world.prompts.length).toBe(1); + const login = world.calls.find(call => call.path === '/api/v1/phone-logins')!; + expect(login.body).toEqual({ device_name: 'Test Mac', numbers: [B] }); +}); + +test('choosing all sends one code per number', async () => { + world.aliases = [A, B]; + world.interactive = true; + world.answers = ['all']; + world.verified = [A, B]; + expect(await runOnboard(['--no-wire'], deps())).toBe(0); + const loginMessages = world.sent.map(args => args.at(-2)); + expect(loginMessages.slice(0, 2)).toEqual([message(A), message(B)]); +}); + +test('sends values as argv so metacharacters stay data', async () => { + const hostile = 'x" & (do shell script "id") & "`$(id)`;\''; + world.responses['/api/v1/phone-logins'] = { status: 200, body: { data: { device_code: DEVICE, receiving_number: LINE, expires_in: 600, interval: 2, challenges: [{ number: A, message: hostile }] } } }; + expect(await run('--yes', '--no-wire', '--skip-demo')).toBe(0); + const args = world.sent[0]!; + expect(args.at(-2)).toBe(hostile); + expect(args.at(-1)).toBe(LINE); + const script = args.slice(0, -2).join('\n'); + expect(script).not.toContain('do shell script'); + expect(script).toContain('send (item 1 of argv) to participant (item 2 of argv)'); +}); + +test('refuses server values that could be read as options or point at other numbers', async () => { + world.responses['/api/v1/phone-logins'] = { status: 200, body: { data: { device_code: DEVICE, receiving_number: LINE, expires_in: 600, interval: 2, challenges: [{ number: A, message: '-e do shell script' }] } } }; + expect(await run('--yes')).toBe(1); + expect(failure().code).toBe('backend_response_invalid'); + world.responses['/api/v1/phone-logins'] = { status: 200, body: { data: { device_code: DEVICE, receiving_number: LINE, expires_in: 600, interval: 2, challenges: [{ number: FOREIGN, message: 'CODE' }] } } }; + expect(await run('--yes')).toBe(1); + expect(failure().code).toBe('backend_response_invalid'); + expect(world.sent).toEqual([]); +}); + +test('automation denial names the setting and no sign-in is left behind', async () => { + world.execFail.osascript = { stderr: 'execution error: Not authorized to send Apple events to Messages. (-1743)' }; + expect(await run('--yes')).toBe(1); + expect(failure().code).toBe('messages_permission_denied'); + expect(failure().error).toContain('Privacy & Security > Automation'); + expect(existsSync(credentials())).toBe(false); + expect(existsSync(join(world.home, '.chert', 'onboarding'))).toBe(false); +}); + +test('other send failures do not repeat what Messages said', async () => { + world.execFail.osascript = { stderr: `some error mentioning ${A}` }; + expect(await run('--yes')).toBe(1); + expect(failure().code).toBe('messages_send_failed'); + expect(everything()).not.toContain(A); +}); + +test('runs the whole flow: code, sign-in, trial, verification, demo', async () => { + expect(await run('--yes', '--no-wire')).toBe(0); + expect(records().map(r => r.code)).toEqual(['numbers_detected', 'login_code_sent', 'signed_in', 'trial_status', 'verification_sent', 'demo_requested']); + expect(world.sent.map(args => args.slice(-2))).toEqual([[message(A), LINE], [CODE, LINE]]); + const onboarding = world.calls.find(call => call.path === '/api/v1/onboarding')!; + expect(onboarding.headers.get('authorization')).toBe(`Bearer ${TOKEN}`); + expect(onboarding.headers.get('x-chert-project-id')).toBe(PROJECT); + expect(onboarding.headers.get('idempotency-key')).toBeTruthy(); + const demo = world.calls.find(call => call.path.endsWith('/demo'))!; + expect(demo.body).toEqual({ when_busy: 'queue' }); + const verify = world.calls.find(call => call.path.endsWith('/verify'))!; + expect(verify.body).toEqual({ recipient: A }); + const requested = records().find(r => r.code === 'demo_requested')!; + expect(requested.message).toContain('does not confirm that audio or video worked'); + expect(records().find(r => r.code === 'trial_status')!.allowance).toEqual({ plan: 'trial', minutes_left: 10 }); +}); + +test('never prints a code, token, device code or full number', async () => { + mkdirSync(join(world.cwd, '.git')); + expect(await run('--yes')).toBe(0); + const text = everything(); + for (const secret of [TOKEN, DEVICE, CODE, message(A), A, LINE]) expect(text).not.toContain(secret); +}); + +test('saves the sign-in in the format and place the chert CLI reads', async () => { + expect(await run('--yes', '--no-wire', '--skip-demo')).toBe(0); + const saved = JSON.parse(readFileSync(credentials(), 'utf8')); + expect(saved).toEqual({ access_token: TOKEN, expires_at: 1_000_000 + 86400_000, kind: 'cli', project_id: PROJECT, device_name: 'Test Mac', origin: 'https://facetime.trychert.com' }); + expect(statSync(credentials()).mode & 0o777).toBe(0o600); + expect(statSync(join(world.home, '.chert')).mode & 0o777).toBe(0o700); + const resumeFile = join(world.home, '.chert', 'onboarding', `${createHash('sha256').update(JSON.stringify(['https://facetime.trychert.com', PROJECT])).digest('hex')}.json`); + expect(JSON.parse(readFileSync(resumeFile, 'utf8'))).toEqual({ request_key: expect.any(String), session_id: SESSION }); +}); + +test('a number Messages did not send from is reported, not claimed', async () => { + world.aliases = [A, B]; + world.finishOnly = true; + expect(await run('--yes', '--no-wire')).toBe(0); + const signedIn = records().find(r => r.code === 'signed_in')!; + expect(signedIn).toMatchObject({ verified: ['•••0100'], unverified: ['•••0199'] }); + expect(signedIn.message).toContain('Start New Conversations From'); + const finished = world.calls.filter(call => call.path.endsWith('/token')).at(-1)!; + expect(finished.body).toEqual({ device_code: DEVICE, finish: true }); + expect(world.now - 1_000_000).toBeLessThan(60_000); + expect(records().find(r => r.code === 'demo_requested')!.message).not.toContain('first one verified'); +}); + +test('two verified numbers say which one is called, and a taken number is reported', async () => { + world.aliases = [A, B]; + world.verified = [A, B]; + world.unlinked = [B]; + expect(await run('--yes', '--no-wire')).toBe(0); + expect(records().find(r => r.code === 'signed_in')).toMatchObject({ unlinked: ['•••0199'] }); + expect(records().find(r => r.code === 'demo_requested')!.message).not.toContain('first one verified'); +}); + +test('the demo never calls a number that belongs to another account', async () => { + world.aliases = [A, B]; + world.verified = [A, B]; + world.unlinked = [A]; + expect(await run('--yes', '--no-wire')).toBe(0); + expect(records().find(r => r.code === 'demo_requested')).toMatchObject({ number: '•••0199' }); +}); + +test('a demo that cannot start still wires the project and reports the failure', async () => { + writeFileSync(join(world.cwd, 'package.json'), '{}'); + world.responses[`/api/v1/onboarding/${SESSION}/demo`] = { status: 409, body: { error: { code: 'outbound_unavailable', message: 'server wording' } } }; + expect(await run('--yes')).toBe(1); + expect(records().some(r => r.code === 'wire_in')).toBe(true); + expect(world.err.join('\n')).toContain('outbound_unavailable'); +}); + +test('two linked numbers say which one is called', async () => { + world.aliases = [A, B]; + world.verified = [A, B]; + expect(await run('--yes', '--no-wire')).toBe(0); + expect(records().find(r => r.code === 'demo_requested')!.message).toContain('first one verified'); +}); + +test('no verified number times out, asks for the sender setting and starts fresh next time', async () => { + world.verified = []; + expect(await run('--yes')).toBe(1); + expect(failure().code).toBe('verification_timeout'); + expect(failure().error).toContain('Start New Conversations From'); + expect(world.calls.filter(call => call.path.endsWith('/token')).some(call => (call.body as { finish?: boolean }).finish)).toBe(true); + expect(existsSync(credentials())).toBe(false); + expect(readdirNames(join(world.home, '.chert', 'onboarding'))).toEqual([]); +}); + +test('a crash after sending resumes the same login without sending again, and keeps no code', async () => { + const first = deps(); + const fetcher = first.fetch; + first.fetch = (async (input: URL, init: RequestInit) => { + if (new URL(input).pathname.endsWith('/token')) throw new Error('offline'); + return fetcher(input, init); + }) as unknown as typeof fetch; + first.sleep = async () => { throw new Error('killed'); }; + expect(await runOnboard(['--json', '--yes'], first)).toBe(1); + expect(world.sent.length).toBe(1); + const name = readdirNames(join(world.home, '.chert', 'onboarding')).find(file => file.startsWith('phone-login-'))!; + const pendingText = readFileSync(join(world.home, '.chert', 'onboarding', name), 'utf8'); + expect(pendingText).not.toContain('SECRET'); + expect(pendingText).not.toContain(CODE); + expect(statSync(join(world.home, '.chert', 'onboarding', name)).mode & 0o777).toBe(0o600); + world.out = []; + expect(await run('--yes', '--no-wire', '--skip-demo')).toBe(0); + expect(records().map(r => r.code)).toContain('login_resumed'); + expect(world.calls.filter(call => call.path === '/api/v1/phone-logins').length).toBe(1); + expect(readdirNames(join(world.home, '.chert', 'onboarding')).some(file => file.startsWith('phone-login-'))).toBe(false); +}); + +test('a rerun reuses the sign-in and session, and sends nothing already done', async () => { + expect(await run('--yes', '--no-wire')).toBe(0); + const sent = world.sent.length; + world.out = []; + world.calls = []; + expect(await run('--yes', '--no-wire')).toBe(0); + expect(world.sent.length).toBe(sent); + expect(world.calls.some(call => call.path === '/api/v1/phone-logins')).toBe(false); + expect(world.calls.some(call => call.path === '/api/v1/onboarding')).toBe(false); + expect(world.calls.some(call => call.path === `/api/v1/onboarding/${SESSION}` && call.method === 'GET')).toBe(true); + expect(records().map(r => r.code)).toContain('demo_requested'); +}); + +test('a different sign-in is never overwritten', async () => { + mkdirSync(join(world.home, '.chert'), { mode: 0o700 }); + writeFileSync(credentials(), JSON.stringify({ access_token: 'browser-session', expires_at: world.now + 1000 }), { mode: 0o600 }); + expect(await run('--yes')).toBe(1); + expect(failure().code).toBe('existing_signin_conflict'); + expect(JSON.parse(readFileSync(credentials(), 'utf8')).access_token).toBe('browser-session'); + expect(world.sent).toEqual([]); +}); + +test('a readable-by-others credential file is refused', async () => { + mkdirSync(join(world.home, '.chert'), { mode: 0o700 }); + writeFileSync(credentials(), '{}'); + chmodSync(credentials(), 0o644); + expect(await run('--yes')).toBe(1); + expect(failure().code).toBe('unsafe_file'); + expect(world.sent).toEqual([]); +}); + +test('known server errors get their action, and unknown ones reveal nothing', async () => { + world.responses['/api/v1/onboarding'] = { status: 402, body: { error: { code: 'shared_trial_exhausted', message: 'server wording' } } }; + expect(await run('--yes', '--no-wire')).toBe(1); + expect(failure().code).toBe('shared_trial_exhausted'); + expect(failure().error).toContain('already used its trial'); + world.responses['/api/v1/onboarding'] = { status: 500, body: { error: { code: 'weird_failure', message: 'private internal detail' } } }; + expect(await run('--yes', '--no-wire')).toBe(1); + expect(failure().code).toBe('onboarding_not_ready'); + expect(everything()).not.toContain('private internal detail'); +}); + +test('a server without this feature says so plainly', async () => { + world.responses['/api/v1/phone-logins'] = { status: 404, body: {} }; + expect(await run('--yes')).toBe(1); + expect(failure().code).toBe('onboarding_unavailable'); + expect(world.sent).toEqual([]); +}); + +test('reports the queue position while waiting for a busy line', async () => { + world.session = { status: 'ready' }; + let polls = 0; + const wrapped = deps(), fetcher = wrapped.fetch; + wrapped.fetch = (async (input: URL, init: RequestInit) => { + const path = new URL(input).pathname; + if (path.endsWith('/demo')) return Response.json({ data: { id: SESSION, status: 'ready', line: { id: PROJECT, receiving_number: LINE }, demo_queue: { state: 'waiting', position: 2 } } }); + if (path === `/api/v1/onboarding/${SESSION}`) { + polls++; + return Response.json({ data: { id: SESSION, line: { id: PROJECT, receiving_number: LINE }, ...(polls === 1 ? { status: 'ready', demo_queue: { state: 'waiting', position: 1 } } : { status: 'demo_running', demo_call_id: CALL }) } }); + } + return fetcher(input, init); + }) as unknown as typeof fetch; + expect(await runOnboard(['--json', '--yes', '--no-wire'], wrapped)).toBe(0); + expect(records().filter(r => r.code === 'demo_queued').map(r => r.position)).toEqual([2, 1]); + expect(records().map(r => r.code)).toContain('demo_requested'); +}); + +test('a queue wait that ended is not reported as a call', async () => { + world.session = { status: 'ready' }; + world.responses[`/api/v1/onboarding/${SESSION}/demo`] = { status: 200, body: { data: { id: SESSION, status: 'ready', line: { id: PROJECT, receiving_number: LINE }, demo_queue: { state: 'expired', position: null } } } }; + expect(await run('--yes', '--no-wire')).toBe(1); + expect(failure()).toMatchObject({ code: 'demo_not_started', queue_state: 'expired' }); +}); + +test('detects projects by manifest and picks the install command', () => { + const dir = join(root, 'detect'); + const inProject = (...files: string[]) => { + rmSync(dir, { recursive: true, force: true }); mkdirSync(dir); + for (const file of files) writeFileSync(join(dir, file), ''); + return detectProject(dir); + }; + expect(inProject()).toBeNull(); + expect(inProject('notes.txt')).toBeNull(); + expect(inProject('package.json')).toEqual({ stack: 'javascript', install_command: 'npm install @trychert/sdk' }); + expect(inProject('package.json', 'bun.lock')!.install_command).toBe('bun add @trychert/sdk'); + expect(inProject('package.json', 'pnpm-lock.yaml')!.install_command).toBe('pnpm add @trychert/sdk'); + expect(inProject('pyproject.toml', 'uv.lock')!.install_command).toBe('uv add chert-sdk'); + expect(inProject('requirements.txt')!.install_command).toBe('python -m pip install chert-sdk'); + for (const [file, stack] of [['Cargo.toml', 'rust'], ['go.mod', 'go'], ['App.csproj', 'dotnet'], ['CMakeLists.txt', 'cpp']] as const) { + expect(inProject(file)).toMatchObject({ stack, install_command: null }); + } + expect(inProject('.git')).toMatchObject({ stack: 'unknown', install_command: null }); +}); + +test('a project gets a handoff and an ignored .env.local, with its other lines kept', async () => { + writeFileSync(join(world.cwd, 'package.json'), '{}'); + writeFileSync(join(world.cwd, '.env.local'), 'OTHER=keep\n'); + expect(await run('--yes')).toBe(0); + const wire = records().find(r => r.code === 'wire_in')!; + expect(wire).toMatchObject({ stack: 'javascript', install_command: 'npm install @trychert/sdk', env_vars: ['CHERT_FACETIME_TOKEN', 'CHERT_PROJECT_ID'], env_local_written: true }); + expect(wire.prompt).toContain('npm install @trychert/sdk'); + expect(JSON.stringify(wire)).not.toContain(TOKEN); + expect(readFileSync(join(world.cwd, '.env.local'), 'utf8')).toBe(`OTHER=keep\nCHERT_FACETIME_TOKEN=${TOKEN}\nCHERT_PROJECT_ID=${PROJECT}\n`); + expect(statSync(join(world.cwd, '.env.local')).mode & 0o777).toBe(0o600); + expect(readFileSync(join(world.cwd, 'package.json'), 'utf8')).toBe('{}'); + expect(world.installs).toEqual([]); +}); + +test('a project whose .env.local git does not ignore gets no credential on disk', async () => { + writeFileSync(join(world.cwd, 'package.json'), '{}'); + world.ignored = false; + expect(await run('--yes')).toBe(0); + expect(records().find(r => r.code === 'wire_in')).toMatchObject({ env_local_written: false }); + expect(existsSync(join(world.cwd, '.env.local'))).toBe(false); +}); + +test('an existing different value or symlinked .env.local is left alone', async () => { + writeFileSync(join(world.cwd, 'package.json'), '{}'); + writeFileSync(join(world.cwd, '.env.local'), 'CHERT_PROJECT_ID=someone-elses\n'); + expect(await run('--yes')).toBe(0); + expect(records().find(r => r.code === 'wire_in')).toMatchObject({ env_local_written: false }); + expect(readFileSync(join(world.cwd, '.env.local'), 'utf8')).toBe('CHERT_PROJECT_ID=someone-elses\n'); + rmSync(join(world.cwd, '.env.local')); + const elsewhere = join(root, 'elsewhere'); + writeFileSync(elsewhere, 'X=1\n'); + symlinkSync(elsewhere, join(world.cwd, '.env.local')); + world.out = []; + expect(await run('--yes')).toBe(0); + expect(records().find(r => r.code === 'wire_in')).toMatchObject({ env_local_written: false }); + expect(readFileSync(elsewhere, 'utf8')).toBe('X=1\n'); +}); + +test('no project with the CLI present reports it ready', async () => { + expect(await run('--yes')).toBe(0); + expect(records().at(-1)).toMatchObject({ code: 'personal_agent_ready', next_commands: ['chert doctor', 'chert dev'] }); + expect(world.installs).toEqual([]); +}); + +test('no project installs the published CLI without asking', async () => { + world.chertInstalled = false; + expect(await run('--numbers', '1')).toBe(0); + expect(world.prompts).toEqual([]); + expect(world.installs).toEqual([['install', '-g', '@trychert/facetime@beta']]); + expect(records().at(-1)!.code).toBe('personal_agent_ready'); +}); + +test('an unavailable CLI is reported as such and is not success', async () => { + world.chertInstalled = false; + world.npmPublished = false; + expect(await run('--yes')).toBe(1); + expect(records().at(-1)!.code).toBe('personal_agent_cli_unavailable'); + expect(world.installs).toEqual([]); +}); + +test('--no-wire skips the directory step', async () => { + writeFileSync(join(world.cwd, 'package.json'), '{}'); + expect(await run('--yes', '--no-wire')).toBe(0); + expect(records().map(r => r.code)).not.toContain('wire_in'); + expect(existsSync(join(world.cwd, '.env.local'))).toBe(false); +}); + +test('the origin can be overridden only by a plain https address', async () => { + const unsafe = deps(); + unsafe.env = { CHERT_FACETIME_URL: 'http://example.test' }; + expect(await runOnboard(['--json', '--yes'], unsafe)).toBe(1); + expect(failure().code).toBe('invalid_origin'); + expect(world.sent).toEqual([]); +}); + +function readdirNames(dir: string): string[] { + return existsSync(dir) ? Array.from(new Bun.Glob('*').scanSync({ cwd: dir, dot: true })) : []; +} + +test('one number on the Mac runs from start to call with no prompt and one message', async () => { + world.interactive = true; + world.proven = true; + expect(await runOnboard(['--no-wire'], deps())).toBe(0); + expect(world.prompts).toEqual([]); + expect(world.sent.length).toBe(1); + expect(world.out.join('\n')).toContain('Answer the FaceTime call'); +}); + +function limitedVerify(times: number): OnboardDeps { + const base = deps(), real = base.fetch; + let left = times; + return { ...base, fetch: (async (input: URL, init: RequestInit) => { + if (new URL(input).pathname.endsWith('/verify') && left-- > 0) { + world.calls.push({ path: new URL(input).pathname, body: undefined, headers: new Headers(init.headers), method: init.method ?? 'GET' }); + return Response.json({ error: { code: 'verification_rate_limited' } }, { status: 429 }); + } + return real(input as never, init); + }) as unknown as typeof fetch }; +} + +test('a verification rate limit is waited out with no second run', async () => { + world.proven = true; + expect(await runOnboard(['--json', '--no-wire'], limitedVerify(3))).toBe(0); + expect(records().filter(record => record.code === 'verification_waiting').length).toBe(1); + expect(records().some(record => record.code === 'demo_requested')).toBe(true); + expect(world.calls.filter(call => call.path.endsWith('/verify')).length).toBe(4); +}); + +test('a rate limit that outlasts the wait still stops with the same error', async () => { + world.proven = true; + const started = world.now; + expect(await runOnboard(['--json', '--no-wire'], limitedVerify(Number.MAX_SAFE_INTEGER))).toBe(1); + expect(failure().code).toBe('verification_rate_limited'); + expect(world.now - started).toBeLessThanOrEqual(40_000); + expect(records().some(record => record.code === 'demo_requested')).toBe(false); +}); + +test('with no number in Messages it asks for one and tells the person what to text', async () => { + world.aliases = []; + world.interactive = true; + world.answers = [FOREIGN]; + world.proven = true; + world.verified = [FOREIGN]; + expect(await runOnboard(['--no-wire'], deps())).toBe(0); + expect(world.prompts[0]).toContain('What is your phone number?'); + expect(world.sent).toEqual([]); + expect(world.calls.find(call => call.path === '/api/v1/phone-logins')!.body).toMatchObject({ numbers: [FOREIGN], manual: true }); + expect(world.out.join('\n')).toContain(`from ${FOREIGN}`); +}); + +test('a demo that did not connect is taken again in a new session', async () => { + world.proven = true; + expect(await run('--no-wire')).toBe(0); + world.session = { status: 'complete', demo_result: 'not_completed', demo_call_id: PROJECT }; + world.calls = []; world.out = []; + const started = world.calls.length; + expect(await run('--no-wire')).toBe(0); + expect(records().some(record => record.code === 'demo_retry')).toBe(true); + const creates = world.calls.slice(started).filter(call => call.path === '/api/v1/onboarding'); + expect(creates.length).toBe(1); +}); diff --git a/packages/unified/onboard.ts b/packages/unified/onboard.ts new file mode 100644 index 0000000..33d3a52 --- /dev/null +++ b/packages/unified/onboard.ts @@ -0,0 +1,551 @@ +import { execFile } from 'node:child_process'; +import { randomUUID, createHash } from 'node:crypto'; +import { chmodSync, closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { homedir, hostname } from 'node:os'; +import { join } from 'node:path'; +import { parseArgs } from 'node:util'; +import { createInterface } from 'node:readline/promises'; + +const DOCS = 'https://github.com/cherthq/sdk/blob/main'; +const DEFAULT_ORIGIN = 'https://facetime.trychert.com'; +const AGENT_CLI = '@trychert/facetime'; +const E164 = /^\+[1-9][0-9]{6,14}$/; +const UUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; +const CREDENTIAL = /^ftu_[A-Za-z0-9_-]{43}$/; +const DEVICE_CODE = /^[\x21-\x7e]{16,200}$/; +const OUTGOING_MESSAGE = /^[!-,.-~][ -~]{0,159}$/; +const SEND_SCRIPT = ['on run argv', 'tell application "Messages"', 'set targetService to 1st account whose service type = iMessage', + 'send (item 1 of argv) to participant (item 2 of argv) of targetService', 'end tell', 'end run']; +const FINISH_AFTER_MS = 60_000; +const VERIFY_LIMIT_WAIT_MS = 35_000; +const VERIFY_LIMIT_POLL_MS = 3_000; +const STALLED_AFTER_MS = 15_000; +const LOGIN_WAIT_MS = 120_000; +const SESSION_WAIT_MS = 600_000; + +export interface ExecResult { stdout: string } +export interface OnboardDeps { + exec(file: string, args: string[], timeoutMs: number): Promise; + fetch: typeof fetch; + prompt(question: string): Promise; + now(): number; + sleep(ms: number): Promise; + out(line: string): void; + err(line: string): void; + platform: string; + interactive: boolean; + home: string; + cwd: string; + env: NodeJS.ProcessEnv; + deviceName: string; +} + +export function defaultDeps(): OnboardDeps { + return { + exec: (file, args, timeout) => new Promise((resolve, reject) => { + execFile(file, args, { timeout, maxBuffer: 8 << 20 }, (error, stdout, stderr) => { + if (error) reject(Object.assign(error, { stderr: String(stderr) })); else resolve({ stdout: String(stdout) }); + }); + }), + fetch: globalThis.fetch, + async prompt(question) { + const reader = createInterface({ input: process.stdin, output: process.stdout }); + try { return await reader.question(question); } finally { reader.close(); } + }, + now: Date.now, + sleep: ms => new Promise(resolve => setTimeout(resolve, ms)), + out: line => console.log(line), + err: line => console.error(line), + platform: process.platform, + interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY), + home: homedir(), + cwd: process.cwd(), + env: process.env, + deviceName: hostname(), + }; +} + +export class OnboardError extends Error { + constructor(readonly code: string, message: string, readonly detail: Record = {}) { super(message); } +} + +export const maskNumber = (number: string) => `•••${number.slice(-4)}`; + +export function e164(value: unknown): string | null { + if (typeof value !== 'string') return null; + const text = value.trim(); + return E164.test(text) ? text : null; +} + +export function parseMessagesAliases(json: string): string[] { + let value: unknown; + try { value = JSON.parse(json); } catch { return []; } + const aliases = (value as { allAliases?: unknown } | null)?.allAliases; + if (!Array.isArray(aliases)) return []; + return [...new Set(aliases.flatMap(alias => e164(alias) ?? []))]; +} + +async function detectNumbers(deps: OnboardDeps): Promise { + const plist = join(deps.home, 'Library', 'Preferences', 'com.apple.madrid.plist'); + let numbers: string[] = []; + try { numbers = parseMessagesAliases((await deps.exec('plutil', ['-extract', 'IMD-IDS-Aliases', 'json', '-o', '-', plist], 15_000)).stdout); } catch {} + return numbers; +} +function noNumbers(): OnboardError { + return new OnboardError('no_phone_numbers', 'No phone number is signed in to Messages on this Mac. Turn on your number in Messages > Settings > iMessage, or on your iPhone under Settings > Apps > Messages > Text Message Forwarding, then run this again.'); +} + +function parseSelection(raw: string, detected: string[]): string[] { + const text = raw.trim().toLowerCase(); + if (text === 'all' || text === 'both') return detected; + const picks = raw.split(',').map(part => part.trim()).filter(Boolean); + if (!picks.length) throw new OnboardError('invalid_numbers', 'Choose at least one number.'); + const chosen = picks.map(pick => { + if (/^[0-9]{1,2}$/.test(pick)) { + const number = detected[Number(pick) - 1]; + if (!number) throw new OnboardError('number_not_on_this_mac', `There is no number ${pick} on this Mac. Nothing was sent.`); + return number; + } + const number = e164(pick); + if (!number) throw new OnboardError('invalid_numbers', 'Use numbers with their + country code, or their position in the list. Nothing was sent.'); + if (!detected.includes(number)) throw new OnboardError('number_not_on_this_mac', `${maskNumber(number)} is not signed in to Messages on this Mac. Nothing was sent.`); + return number; + }); + return [...new Set(chosen)]; +} + +function chertHome(deps: OnboardDeps): string { return deps.env.CHERT_HOME?.trim() || join(deps.home, '.chert'); } + +function ownerOnly(path: string, max = 16384) { + const stat = lstatSync(path); + if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || (stat.mode & 0o077) !== 0 || stat.uid !== process.getuid?.() || stat.size > max) + throw new OnboardError('unsafe_file', `${path} must be a regular file only you can read. Fix it, or remove it, then run this again.`); +} +function writeOwnerOnly(dir: string, name: string, contents: string) { + mkdirSync(dir, { recursive: true, mode: 0o700 }); chmodSync(dir, 0o700); + const file = join(dir, name), temporary = `${file}.${process.pid}.${randomUUID()}.tmp`; + const fd = openSync(temporary, 'wx', 0o600); + try { writeFileSync(fd, contents); fsyncSync(fd); } finally { closeSync(fd); } + renameSync(temporary, file); +} +function readOwnerOnly(path: string): unknown { + if (!existsSync(path)) return undefined; + ownerOnly(path); + try { return JSON.parse(readFileSync(path, 'utf8')); } + catch { throw new OnboardError('unsafe_file', `${path} is unreadable. Remove it, then run this again.`); } +} + +interface SavedCredential { access_token: string; expires_at: number; kind: 'cli'; project_id: string; device_name?: string; origin: string } +function loadCredential(deps: OnboardDeps, origin: string): SavedCredential | undefined { + const value = readOwnerOnly(join(chertHome(deps), 'credentials.json')) as Partial | undefined; + if (!value || typeof value.expires_at !== 'number' || value.expires_at <= deps.now()) return undefined; + if (value.kind === 'cli' && typeof value.access_token === 'string' && CREDENTIAL.test(value.access_token) && typeof value.project_id === 'string' && UUID.test(value.project_id) && (value.origin ?? DEFAULT_ORIGIN) === origin) + return { ...value, origin } as SavedCredential; + throw new OnboardError('existing_signin_conflict', 'This computer is already signed in to Chert another way. Run chert logout, or set CHERT_HOME to another folder, then run this again.'); +} + +interface PendingLogin { device_code: string; expires_at: number; interval: number; numbers: string[] } +const pendingFile = (deps: OnboardDeps, origin: string) => join(chertHome(deps), 'onboarding', `phone-login-${digest([origin])}.json`); +function loadPending(deps: OnboardDeps, origin: string): PendingLogin | undefined { + const value = readOwnerOnly(pendingFile(deps, origin)) as Partial | undefined; + if (!value || typeof value.device_code !== 'string' || typeof value.expires_at !== 'number' || typeof value.interval !== 'number' || !Array.isArray(value.numbers)) return undefined; + return value.expires_at > deps.now() ? value as PendingLogin : undefined; +} +const digest = (value: unknown) => createHash('sha256').update(JSON.stringify(value)).digest('hex'); + +interface OnboardingResume { request_key: string; session_id?: string } +const resumeName = (origin: string, project: string) => `${digest([origin, project])}.json`; +function loadResume(deps: OnboardDeps, origin: string, project: string): OnboardingResume { + const dir = join(chertHome(deps), 'onboarding'), value = readOwnerOnly(join(dir, resumeName(origin, project))) as Partial | undefined; + if (value && typeof value.request_key === 'string' && UUID.test(value.request_key)) return value as OnboardingResume; + const created = { request_key: randomUUID() }; + saveResume(deps, origin, project, created); + return created; +} +function saveResume(deps: OnboardDeps, origin: string, project: string, value: OnboardingResume) { + writeOwnerOnly(join(chertHome(deps), 'onboarding'), resumeName(origin, project), JSON.stringify(value) + '\n'); +} + +const actions: Record = { + phone_login_expired: 'This sign-in expired. Run chert-sdk onboard again.', + phone_login_not_found: 'This sign-in is no longer valid. Run chert-sdk onboard again.', + phone_login_rate_limited: 'Too many sign-ins from here. Wait a few minutes and run chert-sdk onboard again.', + phone_login_unavailable: 'This number cannot sign in this way. Sign in with chert login --browser, or try again later.', + shared_worker_upgrade_required: 'This line has not passed shared-call qualification. Your onboarding state is saved; retry later.', + line_busy: 'The line is serving a call or awaiting cleanup. Run chert-sdk onboard again shortly.', + outbound_unavailable: 'The assigned line cannot place the hosted demo yet. Your verification is saved; contact Chert support.', + shared_line_incompatible: 'This line has not passed shared-call qualification. Your onboarding state is saved; contact Chert support.', + shared_cleanup_pending: 'The line is waiting for cleanup of its previous call. Run chert-sdk onboard again shortly.', + outbound_audio_unsupported: 'The assigned line cannot place the audio demo right now. Your verification is saved; contact Chert support.', + outbound_video_unsupported: 'The assigned line cannot place the video demo right now. Your verification is saved; contact Chert support.', + outbound_disabled: 'The hosted demo is temporarily unavailable. Your verification is saved; run chert-sdk onboard again later.', + trial_exhausted: 'This account has already used its trial. Restarting does not reset it.', + shared_trial_exhausted: 'This account has already used its trial. Your project is saved; restarting does not reset the trial.', + shared_trial_unconfigured: 'Trial onboarding is not open yet. Your request is saved; retry later.', + shared_entitlement_expired: 'This project\'s access expired. Your project is saved; review billing to reactivate it.', + shared_allocation_draining: 'The previous allocation still has cleanup or usage to settle. Your project is saved; retry later.', + trial_auth_required: 'This account cannot request a trial yet. Contact Chert support.', + shared_trial_auth_required: 'This account cannot request a trial yet. Contact Chert support.', + shared_no_capacity: 'Every shared slot is in use. Your request is saved; run chert-sdk onboard again later.', + shared_allocation_unavailable: 'The shared allocation is no longer available. Run chert-sdk onboard again.', + shared_project_has_dedicated_line: 'This project already has a dedicated line. Use chert dev for it.', + shared_session_expired: 'This onboarding expired or was cancelled. Run chert-sdk onboard again.', + shared_pool_unavailable: 'Shared onboarding is not enabled for this project yet.', + shared_recipient_conflict: 'This number already routes to another project on the assigned line. Choose another number or contact Chert support.', + recipient_verification_required: 'Your number is not verified yet. Run chert-sdk onboard again.', + recipient_not_verified: 'Your number is not verified yet. Run chert-sdk onboard again.', + recipient_confirmation_pending: 'Verification is finishing. Run chert-sdk onboard again shortly.', + free_credit_required: 'This project needs FaceTime credit before the demo can run.', + credit_exhausted: 'This project is out of FaceTime credit. Your verification is saved.', + shared_minutes_limit: 'This project has used today\'s FaceTime allowance. Retry after the next UTC day starts.', + shared_contact_limit: 'This project has reached its contact limit.', + shared_quota_exceeded: 'This project has reached its shared usage allowance. Retry after it resets.', + shared_line_busy: 'The line is serving a call or awaiting cleanup. Run chert-sdk onboard again shortly.', + onboarding_expired: 'This onboarding expired. Run chert-sdk onboard again.', + demo_unavailable: 'The hosted demo is not available on this line yet. Your verification is saved; retry later.', + verification_rate_limited: 'Wait a moment, then run chert-sdk onboard again.', +}; + +interface Session { + id: string; status: string; expires_at?: string; + line: { id: string; receiving_number: string }; + demo_call_id?: string | null; + demo_result?: string; + demo_queue?: { state?: string; position?: number | null; message?: string }; +} + +class Api { + constructor(private readonly deps: OnboardDeps, private readonly origin: string) {} + async call(path: string, options: { method?: string; body?: unknown; token?: string; project?: string; key?: string } = {}): Promise<{ status: number; data: unknown; code?: string }> { + let response: Response; + try { + response = await this.deps.fetch(new URL(path, this.origin), { + method: options.method ?? 'POST', redirect: 'error', cache: 'no-store', signal: AbortSignal.timeout(15_000), + headers: { 'content-type': 'application/json', + ...(options.token ? { authorization: `Bearer ${options.token}` } : {}), + ...(options.project ? { 'x-chert-project-id': options.project } : {}), + ...(options.key ? { 'idempotency-key': options.key } : {}) }, + ...(options.body !== undefined ? { body: JSON.stringify(options.body) } : {}), + }); + } catch { throw new OnboardError('backend_unreachable', 'Could not reach Chert. Run chert-sdk onboard again to continue.'); } + let body: { data?: unknown; error?: { code?: unknown } } | null = null; + try { body = JSON.parse((await response.text()).slice(0, 65536)); } catch {} + const code = typeof body?.error?.code === 'string' && /^[a-z][a-z0-9_]{0,63}$/.test(body.error.code) ? body.error.code : undefined; + return { status: response.status, data: body?.data, ...(code ? { code } : {}) }; + } + async require(path: string, options: Parameters[1] = {}): Promise { + const result = await this.call(path, options); + if (result.status >= 200 && result.status < 300 && result.data !== undefined) return result.data; + throw failure(result); + } +} +function failure(result: { status: number; code?: string }): OnboardError { + if (result.code && Object.hasOwn(actions, result.code)) return new OnboardError(result.code, actions[result.code]!); + if (result.status === 401) return new OnboardError('login_required', 'Your Chert sign-in expired or was revoked. Run chert-sdk onboard again.'); + if (result.status === 404 || result.status === 405) return new OnboardError('onboarding_unavailable', 'This Chert server does not offer automated onboarding yet. Nothing more was sent.'); + return new OnboardError('onboarding_not_ready', 'Chert could not continue onboarding. Run chert-sdk onboard again to continue the same request.'); +} +const invalid = () => new OnboardError('backend_response_invalid', 'Chert returned an unexpected response. Nothing further was sent.'); + +function parseSession(value: unknown): Session { + const session = value as Partial | null; + if (!session || typeof session.id !== 'string' || !UUID.test(session.id) || typeof session.status !== 'string' || typeof session.line?.receiving_number !== 'string' || !E164.test(session.line.receiving_number)) throw invalid(); + return session as Session; +} + +async function sendFromMessages(deps: OnboardDeps, text: string, to: string) { + if (!OUTGOING_MESSAGE.test(text) || !E164.test(to)) throw invalid(); + const args = [...SEND_SCRIPT.flatMap(line => ['-e', line]), text, to]; + try { await deps.exec('osascript', args, 30_000); } + catch (error) { + const detail = `${(error as { stderr?: unknown }).stderr ?? ''}`; + if (/-1743|not authori[sz]ed/i.test(detail)) throw new OnboardError('messages_permission_denied', 'macOS blocked access to Messages. Allow it in System Settings > Privacy & Security > Automation, under your terminal app, then run this again.'); + throw new OnboardError('messages_send_failed', 'Messages could not send the code. Open Messages, check you are signed in to iMessage, then run this again.'); + } +} + +async function login(deps: OnboardDeps, api: Api, origin: string, emit: Emit, selected: string[], manual: boolean): Promise<{ credential: SavedCredential; verified: string[] }> { + let pending = loadPending(deps, origin); + const file = pendingFile(deps, origin); + if (pending && (pending.numbers.length !== selected.length || pending.numbers.some(number => !selected.includes(number)))) { rmSync(file, { force: true }); pending = undefined; } + if (!pending) { + const started = await api.require('/api/v1/phone-logins', { body: { device_name: deviceName(deps), numbers: selected, ...(manual ? { manual: true } : {}) } }) as + { device_code?: unknown; receiving_number?: unknown; challenges?: unknown; expires_in?: unknown; interval?: unknown }; + const challenges = started.challenges as { number?: unknown; message?: unknown }[]; + if (typeof started.device_code !== 'string' || !DEVICE_CODE.test(started.device_code) || typeof started.receiving_number !== 'string' || !E164.test(started.receiving_number) + || !Array.isArray(challenges) || typeof started.expires_in !== 'number' || typeof started.interval !== 'number' + || challenges.length !== selected.length || selected.some(number => !challenges.some(c => c.number === number)) + || challenges.some(c => typeof c.message !== 'string' || !OUTGOING_MESSAGE.test(c.message))) throw invalid(); + for (const challenge of challenges) { + if (manual) { emit({ code: 'login_code_to_send', number: maskNumber(challenge.number as string), login_code: challenge.message, send_to: started.receiving_number, message: `Text ${challenge.message as string} to ${started.receiving_number} from ${challenge.number as string}. Chert continues as soon as it arrives.` }); continue; } + await sendFromMessages(deps, challenge.message as string, started.receiving_number); + emit({ code: 'login_code_sent', number: maskNumber(challenge.number as string), message: `Sent a sign-in code to Chert from Messages for ${maskNumber(challenge.number as string)}.` }); + } + pending = { device_code: started.device_code, expires_at: deps.now() + started.expires_in * 1000, interval: started.interval, numbers: selected }; + writeOwnerOnly(join(chertHome(deps), 'onboarding'), `phone-login-${digest([origin])}.json`, JSON.stringify(pending) + '\n'); + } else emit({ code: 'login_resumed', message: 'Continuing the sign-in already in progress.' }); + + const begun = deps.now(), deadline = manual ? pending.expires_at : Math.min(pending.expires_at, begun + LOGIN_WAIT_MS); + let interval = Math.max(pending.interval, 1) * 1000, lastCount = 0, changedAt = begun; + for (;;) { + const stalled = lastCount > 0 && deps.now() - changedAt >= STALLED_AFTER_MS; + const finish = stalled || deps.now() - begun >= FINISH_AFTER_MS; + const result = await api.call('/api/v1/phone-logins/token', { body: { device_code: pending.device_code, ...(finish ? { finish: true } : {}) } }) + .catch((error: unknown) => { if (error instanceof OnboardError && error.code === 'backend_unreachable') return null; throw error; }); + if (result) { + if (result.code === 'slow_down') interval += 1000; + else if (result.status >= 400) { if (['phone_login_expired', 'phone_login_not_found'].includes(result.code ?? '')) rmSync(file, { force: true }); throw failure(result); } + else { + const answer = result.data as { status?: unknown; verified_count?: unknown; access_token?: unknown; project_id?: unknown; verified_numbers?: unknown; unlinked_numbers?: unknown; device_name?: unknown; expires_in?: unknown } | undefined; + if (answer?.status === 'approved') return finishLogin(deps, origin, file, emit, pending, answer); + if (answer?.status !== 'pending') throw invalid(); + const count = typeof answer.verified_count === 'number' ? answer.verified_count : 0; + if (count !== lastCount) { lastCount = count; changedAt = deps.now(); } + } + } + if (deps.now() >= deadline) { + rmSync(file, { force: true }); + throw new OnboardError('verification_timeout', 'Chert did not receive a code from your number. Messages sends from the number under Messages > Settings > iMessage > Start New Conversations From, and a script cannot choose it. Set it to the number you picked, then run this again.'); + } + await deps.sleep(interval); + } +} + +function finishLogin(deps: OnboardDeps, origin: string, file: string, emit: Emit, pending: PendingLogin, answer: { access_token?: unknown; project_id?: unknown; verified_numbers?: unknown; unlinked_numbers?: unknown; device_name?: unknown; expires_in?: unknown }) { + if (typeof answer.access_token !== 'string' || !CREDENTIAL.test(answer.access_token) || typeof answer.project_id !== 'string' || !UUID.test(answer.project_id) + || typeof answer.expires_in !== 'number' || !Array.isArray(answer.verified_numbers) || !answer.verified_numbers.length || answer.verified_numbers.some(number => !pending.numbers.includes(number as string))) throw invalid(); + const credential: SavedCredential = { access_token: answer.access_token, expires_at: deps.now() + answer.expires_in * 1000, kind: 'cli', project_id: answer.project_id, device_name: typeof answer.device_name === 'string' ? answer.device_name.slice(0, 200) : deviceName(deps), origin }; + writeOwnerOnly(chertHome(deps), 'credentials.json', JSON.stringify(credential) + '\n'); + rmSync(file, { force: true }); + const verified = answer.verified_numbers as string[]; + const unverified = pending.numbers.filter(number => !verified.includes(number)); + const unlinked = Array.isArray(answer.unlinked_numbers) ? answer.unlinked_numbers.filter((n): n is string => typeof n === 'string') : []; + emit({ code: 'signed_in', project_id: answer.project_id, verified: verified.map(maskNumber), unverified: unverified.map(maskNumber), unlinked: unlinked.map(maskNumber), + message: `Signed in with ${verified.map(maskNumber).join(' and ')}.` + + (unverified.length ? ` ${unverified.map(maskNumber).join(' and ')} did not verify: Messages sends from one number at a time, set under Messages > Settings > iMessage > Start New Conversations From. Change it and run this again to add the other number.` : '') + + (unlinked.length ? ` ${unlinked.map(maskNumber).join(' and ')} already belongs to another Chert account, so it was not added.` : '') }); + const linked = verified.filter(number => !unlinked.includes(number)); + if (!linked.length) throw invalid(); + return { credential, verified: linked }; +} + +function deviceName(deps: OnboardDeps): string { + const printable = deps.deviceName.replace(/[^ -~]/g, '').trim().slice(0, 80); + return printable || 'Mac'; +} + +async function requestVerification(deps: OnboardDeps, api: Api, emit: Emit, path: string, options: Parameters[1]): Promise { + const deadline = deps.now() + VERIFY_LIMIT_WAIT_MS; + for (let told = false; ;) { + try { return await api.require(path, options); } + catch (error) { + if (!(error instanceof OnboardError) || error.code !== 'verification_rate_limited' || deps.now() + VERIFY_LIMIT_POLL_MS > deadline) throw error; + if (!told) emit({ code: 'verification_waiting', message: 'Chert allows one verification every 30 seconds. Waiting, then continuing.' }); + told = true; + await deps.sleep(VERIFY_LIMIT_POLL_MS); + } + } +} + +async function onboarding(deps: OnboardDeps, api: Api, origin: string, emit: Emit, credential: SavedCredential, recipient: string, others: number, skipDemo: boolean) { + const auth = { token: credential.access_token, project: credential.project_id }; + let resume = loadResume(deps, origin, credential.project_id); + const started = async () => { + const session = parseSession(await api.require('/api/v1/onboarding', { ...auth, body: {}, key: resume.request_key })); + resume.session_id = session.id; + saveResume(deps, origin, credential.project_id, resume); + return session; + }; + let session = resume.session_id ? parseSession(await api.require(`/api/v1/onboarding/${resume.session_id}`, { ...auth, method: 'GET' })) : await started(); + if (session.id !== (resume.session_id ?? session.id)) throw invalid(); + if (session.status === 'expired' || session.status === 'cancelled') { + rmSync(join(chertHome(deps), 'onboarding', resumeName(origin, credential.project_id)), { force: true }); + throw new OnboardError(`onboarding_${session.status}`, 'This onboarding has ended. Run chert-sdk onboard again to start a new one.'); + } + // A demo that did not complete can be taken again, in a new session. Its verification still stands if it is recent. + if (session.status === 'complete' && session.demo_result === 'not_completed') { + rmSync(join(chertHome(deps), 'onboarding', resumeName(origin, credential.project_id)), { force: true }); + resume = loadResume(deps, origin, credential.project_id); + session = await started(); + emit({ code: 'demo_retry', session_id: session.id, message: 'The last demo call did not connect, so Chert is trying again.' }); + } + const allowance = await api.call('/api/v1/allowance', { ...auth, method: 'GET' }).catch(() => null); + const trial = Object.fromEntries(Object.entries((allowance?.status === 200 && allowance.data) || {}).filter(([, value]) => ['string', 'number', 'boolean'].includes(typeof value))); + emit({ code: 'trial_status', session_id: session.id, status: session.status, allowance: trial, message: 'Shared access and your trial allowance are set up.' }); + + if (session.status === 'awaiting_verification') { + const challenge = await requestVerification(deps, api, emit, `/api/v1/onboarding/${session.id}/verify`, { ...auth, body: { recipient }, key: `${resume.request_key}:verify` }) as { code?: unknown; verified?: unknown }; + // A number that just proved itself at sign-in needs no second message. + let wait = challenge.verified === true ? 0 : 2000; + if (challenge.verified !== true) { + if (typeof challenge.code !== 'string' || !/^\d{6}$/.test(challenge.code)) throw invalid(); + await sendFromMessages(deps, challenge.code, session.line.receiving_number); + emit({ code: 'verification_sent', number: maskNumber(recipient), message: `Sent the call verification code from Messages for ${maskNumber(recipient)}.` }); + } + const deadline = deps.now() + SESSION_WAIT_MS; + while (session.status === 'awaiting_verification') { + if (deps.now() >= deadline) throw new OnboardError('verification_timeout', 'Chert did not confirm the code. Check that Messages sends from the number you picked, then run this again.'); + await deps.sleep(wait); + wait = 2000; + session = parseSession(await api.require(`/api/v1/onboarding/${session.id}`, { ...auth, method: 'GET' })); + } + } + if (skipDemo) { emit({ code: 'demo_skipped', status: session.status, message: 'Skipped the demo call.' }); return; } + if (session.status === 'ready') session = parseSession(await api.require(`/api/v1/onboarding/${session.id}/demo`, { ...auth, body: { when_busy: 'queue' }, key: resume.request_key })); + const deadline = deps.now() + SESSION_WAIT_MS; + let reported: number | null | undefined; + while (session.status === 'ready' && session.demo_queue && deps.now() < deadline) { + if (session.demo_queue.position !== reported) { + reported = session.demo_queue.position; + emit({ code: 'demo_queued', position: reported ?? null, message: `The shared line is busy. Waiting in line${typeof reported === 'number' ? ` at position ${reported}` : ''}.` }); + } + if (!['waiting', 'ready', 'placing'].includes(session.demo_queue.state ?? '')) break; + await deps.sleep(3000); + session = parseSession(await api.require(`/api/v1/onboarding/${session.id}`, { ...auth, method: 'GET' })); + } + const also = others > 0 ? ' Chert calls one number, so this goes to the first one verified.' : ''; + if (session.status === 'demo_running') emit({ code: 'demo_requested', call_id: session.demo_call_id ?? null, number: maskNumber(recipient), message: `Chert is calling ${maskNumber(recipient)}. Answer the FaceTime call to try the demo; the request being accepted does not confirm that audio or video worked.${also}` }); + else if (session.status === 'complete') emit({ code: 'demo_finished', call_id: session.demo_call_id ?? null, message: 'The demo call for this onboarding has already ended.' }); + else throw new OnboardError('demo_not_started', 'The demo call did not start yet. Run chert-sdk onboard again to continue.', session.demo_queue?.state ? { queue_state: session.demo_queue.state } : {}); +} + +export function detectProject(cwd: string): { stack: string; install_command: string | null; install_note?: string } | null { + const names = readdirSync(cwd); + const has = (name: string) => names.includes(name); + if (has('package.json')) { + const command = has('bun.lock') || has('bun.lockb') ? 'bun add @trychert/sdk' : has('pnpm-lock.yaml') ? 'pnpm add @trychert/sdk' : has('yarn.lock') ? 'yarn add @trychert/sdk' : 'npm install @trychert/sdk'; + return { stack: 'javascript', install_command: command }; + } + if (has('pyproject.toml') || has('requirements.txt')) return { stack: 'python', install_command: has('uv.lock') ? 'uv add chert-sdk' : 'python -m pip install chert-sdk' }; + const preview = (stack: string) => ({ stack, install_command: null, install_note: `The ${stack} client is a source preview, not a published package. Build it from https://github.com/cherthq/sdk by following its language guide.` }); + if (has('Cargo.toml')) return preview('rust'); + if (has('go.mod')) return preview('go'); + if (names.some(name => name.endsWith('.csproj'))) return preview('dotnet'); + if (has('CMakeLists.txt')) return preview('cpp'); + if (has('.git')) return { stack: 'unknown', install_command: null, install_note: 'No supported language manifest was found. Pick a client from the setup guide.' }; + return null; +} + +async function writeLocalEnv(deps: OnboardDeps, credential: SavedCredential): Promise<{ written: boolean; reason?: string }> { + try { await deps.exec('git', ['-C', deps.cwd, 'check-ignore', '-q', '--', '.env.local'], 10_000); } + catch { return { written: false, reason: 'git does not ignore .env.local here, so the credential was not written. Add .env.local to .gitignore and run this again, or set the variables another way.' }; } + const file = join(deps.cwd, '.env.local'); + const wanted: Record = { CHERT_FACETIME_TOKEN: credential.access_token, CHERT_PROJECT_ID: credential.project_id }; + let lines: string[] = []; + if (existsSync(file)) { + const stat = lstatSync(file); + if (!stat.isFile() || stat.isSymbolicLink()) return { written: false, reason: '.env.local is not a regular file, so it was left alone.' }; + lines = readFileSync(file, 'utf8').split('\n'); + if (lines.at(-1) === '') lines.pop(); + for (const line of lines) { + const match = /^\s*(?:export\s+)?(CHERT_FACETIME_TOKEN|CHERT_PROJECT_ID)=(.*)$/.exec(line); + if (match && match[2]!.trim().replace(/^["']|["']$/g, '') !== wanted[match[1]!]) return { written: false, reason: `.env.local already sets ${match[1]} to something else, so it was left alone.` }; + if (match) delete wanted[match[1]!]; + } + } + const added = Object.entries(wanted).map(([name, value]) => `${name}=${value}`); + if (!added.length) return { written: true }; + const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`; + const fd = openSync(temporary, 'wx', 0o600); + try { writeFileSync(fd, [...lines, ...added].join('\n') + '\n'); fsyncSync(fd); } finally { closeSync(fd); } + renameSync(temporary, file); + chmodSync(file, 0o600); + return { written: true }; +} + +async function wireIn(deps: OnboardDeps, emit: Emit, credential: SavedCredential, project: NonNullable>) { + const env = await writeLocalEnv(deps, credential); + const docs = { setup: `${DOCS}/docs/SETUP.md`, llms: `${DOCS}/llms.txt`, readme: `${DOCS}/README.md` }; + const prompt = `Wire Chert FaceTime into this ${project.stack === 'unknown' ? '' : `${project.stack} `}project. ${project.install_command ? `Install the SDK with \`${project.install_command}\`.` : project.install_note ?? ''} Read ${docs.llms} and ${docs.setup} first, and run \`chert-sdk context\` to see the account. Read CHERT_FACETIME_TOKEN and CHERT_PROJECT_ID from the environment${env.written ? ' (they are in .env.local)' : ''}; never commit them or print them. Follow this project's existing conventions, and test against a real call before saying it works.`; + emit({ code: 'wire_in', stack: project.stack, install_command: project.install_command, ...(project.install_note ? { install_note: project.install_note } : {}), + env_vars: ['CHERT_FACETIME_TOKEN', 'CHERT_PROJECT_ID'], env_local_written: env.written, ...(env.reason ? { env_local_reason: env.reason } : {}), docs, prompt, + message: `Project found (${project.stack}). ${env.written ? 'The credential is in .env.local.' : env.reason}\nPaste this to your coding agent:\n\n${prompt}` }); +} + +async function setupAgentCli(deps: OnboardDeps, emit: Emit): Promise { + const next = ['chert doctor', 'chert dev']; + const ready = () => emit({ code: 'personal_agent_ready', next_commands: next, message: `No project here, so the personal agent CLI is what you need. Next: ${next.join(', then ')}.` }); + try { await deps.exec('chert', ['--version'], 15_000); ready(); return true; } catch {} + const command = `npm install -g ${AGENT_CLI}@beta`; + try { await deps.exec('npm', ['view', AGENT_CLI, 'version'], 30_000); } + catch { + emit({ code: 'personal_agent_cli_unavailable', message: `Could not find ${AGENT_CLI} with npm. Your Chert sign-in is saved. Install Node, then run: ${command}` }); + return false; + } + try { await deps.exec('npm', ['install', '-g', `${AGENT_CLI}@beta`], 300_000); ready(); return true; } + catch { + emit({ code: 'personal_agent_cli_unavailable', install_command: command, message: `The install failed. Your Chert sign-in is saved. Run it yourself: ${command}` }); + return false; + } +} + +type Emit = (record: { code: string; message: string; [key: string]: unknown }) => void; + +function hostedOrigin(value: string | undefined): string { + const text = value?.trim(); + if (!text) return DEFAULT_ORIGIN; + let url: URL; + try { url = new URL(text); } catch { throw new OnboardError('invalid_origin', 'CHERT_FACETIME_URL must be https://HOST.'); } + if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || url.pathname !== '/') throw new OnboardError('invalid_origin', 'CHERT_FACETIME_URL must be https://HOST.'); + return url.origin; +} + +/** A number Messages cannot send for: the person says which and texts the code themselves. */ +async function askForNumber(deps: OnboardDeps, flags: { numbers?: string; json: boolean }): Promise { + const answer = flags.numbers ?? (!flags.json && deps.interactive ? await deps.prompt('No number found in Messages. What is your phone number? ') : undefined); + if (answer === undefined) throw noNumbers(); + const number = e164(answer.trim()); + if (!number) throw new OnboardError('invalid_numbers', 'Use your number with its + country code. Nothing was sent.'); + return [number]; +} + +// Running the command is the agreement. Only a real choice between numbers stops to ask. +async function choose(deps: OnboardDeps, detected: string[], flags: { numbers?: string; yes: boolean; json: boolean }): Promise { + if (flags.numbers !== undefined) return parseSelection(flags.numbers, detected); + if (flags.yes || detected.length === 1) return detected; + if (flags.json || !deps.interactive) throw new OnboardError('numbers_required', 'Ask the user which to use, then rerun with --numbers 1,2 (positions or +numbers), --numbers all, or --yes for all. A message is sent from their Messages app for each.', { numbers: detected.map((number, index) => ({ index: index + 1, number: maskNumber(number) })) }); + deps.out('This Mac has these numbers:'); + detected.forEach((number, index) => deps.out(` ${index + 1}) ${number}`)); + return parseSelection(await deps.prompt(`Which should Chert use? Enter ${detected.map((_, index) => index + 1).join(', ')} or all: `), detected); +} + +export async function runOnboard(args: string[], deps: OnboardDeps = defaultDeps()): Promise { + let json = args.includes('--json'); + const emit: Emit = record => { deps.out(json ? JSON.stringify(record) : record.message); }; + try { + let values; + try { ({ values } = parseArgs({ args, options: { numbers: { type: 'string' }, yes: { type: 'boolean' }, json: { type: 'boolean' }, 'skip-demo': { type: 'boolean' }, 'no-wire': { type: 'boolean' } } })); } + catch { throw new OnboardError('invalid_request', 'Use --numbers +A,+B, --yes, --json, --skip-demo and --no-wire.'); } + json = Boolean(values.json); + if (deps.platform !== 'darwin') throw new OnboardError('macos_required', 'Automated onboarding reads your number from Messages, so it needs a Mac. On another computer use chert login from @trychert/facetime.'); + const origin = hostedOrigin(deps.env.CHERT_FACETIME_URL); + const detected = await detectNumbers(deps); + emit({ code: 'numbers_detected', numbers: detected.map(maskNumber), message: `Found ${detected.length} number${detected.length === 1 ? '' : 's'} in Messages.` }); + const chosen = values.numbers !== undefined ? { numbers: values.numbers } : {}; + const manual = !detected.length; + const selected = manual ? await askForNumber(deps, { ...chosen, json }) : await choose(deps, detected, { ...chosen, yes: Boolean(values.yes), json }); + const api = new Api(deps, origin); + const saved = loadCredential(deps, origin); + const signedIn = saved ? { credential: saved, verified: selected } : await login(deps, api, origin, emit, selected, manual); + if (saved) emit({ code: 'signed_in', project_id: saved.project_id, message: 'Using this computer\'s existing Chert sign-in.' }); + const recipient = signedIn.verified[0]!; + let stopped: unknown; + try { await onboarding(deps, api, origin, emit, signedIn.credential, recipient, signedIn.verified.length - 1, Boolean(values['skip-demo'])); } + catch (error) { + if (error instanceof OnboardError && error.code === 'login_required') { rmSync(join(chertHome(deps), 'credentials.json'), { force: true }); throw error; } + stopped = error; + } + let wired = true; + if (!values['no-wire']) { + const project = detectProject(deps.cwd); + if (project) await wireIn(deps, emit, signedIn.credential, project); + else wired = await setupAgentCli(deps, emit); + } + if (stopped) throw stopped; + return wired ? 0 : 1; + } catch (error) { + const known = error instanceof OnboardError ? error : new OnboardError('onboarding_failed', 'Onboarding stopped. Nothing further was sent. Run chert-sdk onboard again to continue.'); + deps.err(json ? JSON.stringify({ code: known.code, error: known.message, ...known.detail }) : known.message); + return 1; + } +}