diff --git a/CHANGELOG.md b/CHANGELOG.md index db6c735..86d6f51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased + +- `strait test`: one pass/fail list that says you're set up right: the engine answers, the secret key is valid (which workspace, test or live), and the workspace's link domain serves apple-app-site-association and assetlinks.json. Exit 1 on any failure, `--json` for CI. +- `strait events add `: prints ready-to-paste event tracking code for React Native, Flutter, iOS, Android or the web (detected from the project files, or `--platform`), using each SDK's real `trackEvent` call. + ## 0.2.2 (4 Nov 2026) - `strait init` on an Expo Router project whose routes live in `src/app` writes `src/app/+native-intent.tsx` and `src/links.ts` (was `app/` at the root, which Expo Router then ignored) and points you at `src/app/_layout.tsx`. diff --git a/README.md b/README.md index 0482b07..181040e 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ One command sets up Strait deep links in your app. Every link open and install i npx @straitlink/cli init # set up deep links in your app npx @straitlink/cli doctor --host acme.strait.link # check the link host lists your app npx @straitlink/cli links create summer https://acme.com/s # create a link (secret key in env) +npx @straitlink/cli test # key, engine and link domain: pass/fail (secret key in env) +npx @straitlink/cli events add purchase # print the code that tracks an event ``` `strait init` sets up Strait deep links in a React Native, Expo, Flutter or Android (Kotlin) app: @@ -96,6 +98,38 @@ These call the [public API](https://straitlink.in/docs/api/) (`POST /v1/links`, Exit codes: `0` done, `1` the API said no or can't be reached (its message on stderr), `2` usage problem. +## Am I set up right? `strait test` + +```bash +export STRAIT_SECRET_KEY=st_live_… # Dashboard → Settings → Secret keys +npx @straitlink/cli test +``` + +``` +Strait test + + pass Engine https://strait.link answers (version ddca50a) + pass Key valid live key st_live_3f9a… for workspace "Acme" + pass Link domain acme.strait.link (plan starter) + pass iPhone acme.strait.link lists TEAM123.com.acme.shop + pass Android acme.strait.link lists com.acme.shop + +All checks passed. +Your app's own setup (intent filters, Associated Domains): strait doctor --android --ios . +``` + +One read-only pass: the engine answers (`GET /healthz`), the secret key is valid and which workspace it belongs to, test or live (`GET /v1/workspace`), and the workspace's link domain serves `apple-app-site-association` and `assetlinks.json` listing your apps (the same checks as `strait doctor`). A failed check prints its fix; checks that need it are skipped. Exit `1` when any check fails, `2` for a missing or wrong kind of key, so it can gate a CI job. `--json` prints `{ ok, workspace, checks }`. Same key rules as `strait links`: never printed, publishable keys refused. + +## Track an event: `strait events add` + +```bash +npx @straitlink/cli events add purchase # platform detected from the project files +npx @straitlink/cli events add sign_up --platform ios # react-native | flutter | ios | android | web +npx @straitlink/cli events add purchase ./mobile # [dir]: the app folder, default the current one +``` + +Prints ready-to-paste code with each SDK's own call: `trackEvent` in React Native (and Expo), Flutter, iOS and Android, using the client `strait init` wrote; on the web, `reportWebOpen` plus the `POST /v1/event` the app SDKs send (`@straitlink/web` has no event helper yet). The platform is detected from `package.json`, `pubspec.yaml`, Gradle files, an `.xcodeproj` or `Package.swift`. Nothing is written. Event names are 1-64 letters, numbers, spaces and `_ . : -`. + ## Install lines: one constant Every install line the CLI prints comes from `INSTALL` in `src/install.mjs` (`source` + `githubOwner`). It mirrors `install` in `site/site.config.json` and `INSTALL` in `dashboard/src/lib/install.ts`, and `test/install.test.mjs` fails if they drift while the repos sit side by side. Moving the SDK repos to an organisation means changing `githubOwner`. Publishing to npm, pub.dev and Maven Central means changing `source` to `"registry"`. diff --git a/docs/adr/0001-strait-test-and-events-add.md b/docs/adr/0001-strait-test-and-events-add.md new file mode 100644 index 0000000..c1f7434 --- /dev/null +++ b/docs/adr/0001-strait-test-and-events-add.md @@ -0,0 +1,35 @@ +# 0001: `strait test` and `strait events add` + +5 Nov 2026 · item cli-test-events-add + +## Problem +Launch-day developers live in the terminal. Their first support question is "am I set up right?", and +their first event is slowed by looking up each SDK's event call. Time to first event is our activation metric. + +## Who it serves +A developer with a secret key and an app, in a terminal or a CI job. + +## Options +- **Do nothing.** `doctor` checks the link host but not the key or the workspace; event code lives in five READMEs. +- **Extend what exists (chosen).** `strait test` = `GET /healthz` + `GET /v1/workspace` (both live today) + + the existing `runDoctor` on the workspace's link domain. `strait events add` prints the one call each + SDK already has (`trackEvent` in React Native, Flutter, iOS, Android; the web SDK has no event helper, + so it prints the same `POST /v1/event` its own example uses). No engine, SDK or database change. +- **New engine endpoint for a combined check.** Rejected: everything needed is already served. +- **Write the event code into the user's files.** Rejected: we can't know where their purchase happens; + printing is safe and reviewable. + +## Security +- Secret key read from `STRAIT_SECRET_KEY` / `--secret-key` through the existing `secretKey()` and + `apiClient()` (https only, never echoed, errors redacted). Only the key prefix the API returns is printed. +- Read-only calls; `/v1/workspace` is rate limited per workspace (30/min) on the engine. +- The event name is restricted to `A-Z a-z 0-9 space _ . : -` (1-64) so the printed code can't be broken + or injected by quotes, `$` or backslashes; the engine's own limit is 64 characters. + +## Architecture +No new coupling: same endpoints the MCP server and `links` use. Failure modes are reported as `fail` +lines, never thrown: engine down skips the key and domain checks; a bad key skips the domain check. +Exit 0 all pass, 1 any fail, 2 usage error (CI-friendly). + +## Cost +None beyond three GETs per run. diff --git a/src/cli.mjs b/src/cli.mjs index d4ae3ce..6de6a7a 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -9,6 +9,8 @@ import { resolve } from 'node:path'; import { formatDoctor, runDoctor } from './doctor.mjs'; import { ApiError, DEFAULT_API_URL, apiClient, linksCommand, secretKey } from './links.mjs'; import { CLI_VERSION } from './version.mjs'; +import { formatSelfTest, runSelfTest } from './selftest.mjs'; +import { eventsCommand } from './events.mjs'; const OPTIONS = { init: { @@ -43,6 +45,14 @@ const OPTIONS = { force: { type: 'boolean', default: false }, json: { type: 'boolean', default: false }, }, + test: { + 'secret-key': { type: 'string' }, + 'api-url': { type: 'string' }, + json: { type: 'boolean', default: false }, + }, + events: { + platform: { type: 'string' }, + }, }; export const HELP = `strait: set up and check Strait deep links, and manage links @@ -53,6 +63,8 @@ Usage strait links create [options] Create a link strait links list [options] List links strait links qr [options] Save a link's QR code + strait test [options] Check your key, the engine and your link domain + strait events add [dir] [options] Print ready-to-paste code that tracks an event init options --key Publishable key (Dashboard → Get started) @@ -83,6 +95,16 @@ links options --format png|svg, --size , --out , --force qr: output (default .png) --json Print the API answer as JSON +test options + --secret-key, --api-url As for links (env STRAIT_SECRET_KEY, STRAIT_API_URL) + --json Print { ok, workspace, checks } as JSON + Checks the engine answers, the key is valid (which workspace, test or live), and the + workspace's link domain serves apple-app-site-association and assetlinks.json. Exit 1 + when a check fails (so it can run in CI), 2 for a missing or wrong kind of key. + +events options + --platform react-native | flutter | ios | android | web (detected by default) + Environment STRAIT_PUBLISHABLE_KEY, STRAIT_LINK_HOST, STRAIT_SECRET_KEY, STRAIT_API_URL @@ -91,6 +113,8 @@ Examples npx @straitlink/cli init --key st_pub_live_… --host acme.strait.link --dry-run npx @straitlink/cli doctor --host acme.strait.link --android com.acme.shop --ios com.acme.shop STRAIT_SECRET_KEY=st_live_… npx @straitlink/cli links create summer https://acme.com/summer + STRAIT_SECRET_KEY=st_live_… npx @straitlink/cli test + npx @straitlink/cli events add purchase `; /** @@ -122,7 +146,7 @@ export async function main(argv, io = {}) { } if (cmd !== 'init') { try { - return await (cmd === 'doctor' ? doctor : links)(args, { out, err, env, io }); + return await { doctor, links, test: selfTest, events }[cmd](args, { out, err, env, io }); } catch (e) { if (e instanceof UsageError) { err.write(`strait: ${e.message}\n`); @@ -208,6 +232,24 @@ async function doctor({ values: v }, { out, env, io }) { return result.ok ? 0 : 1; } +async function selfTest({ values: v, positionals }, { out, env, io }) { + if (positionals.length) throw new UsageError(`strait test takes no arguments ("${positionals[0]}"). Options: --secret-key, --api-url, --json.`); + const key = secretKey(v['secret-key'] ?? env.STRAIT_SECRET_KEY); + const apiUrl = String(v['api-url'] || env.STRAIT_API_URL || DEFAULT_API_URL).replace(/\/+$/, ''); + const fetchImpl = io.fetch ?? fetch; + const call = apiClient({ apiUrl, key, fetch: fetchImpl, userAgent: `strait-cli/${CLI_VERSION}` }); + const result = await runSelfTest({ call, apiUrl, key, fetch: fetchImpl }); + out.write((v.json ? JSON.stringify(result, null, 2) : formatSelfTest(result)) + '\n'); + return result.ok ? 0 : 1; +} + +async function events({ values: v, positionals }, { out, env, io }) { + const [sub, name, dirArg, extra] = positionals; + if (extra !== undefined) throw new UsageError(`Too many arguments ("${extra}"). Usage: strait events add [dir] [--platform …]`); + out.write(eventsCommand(sub, name === undefined ? [] : [name], v, { dir: resolve(io.cwd ?? process.cwd(), dirArg ?? '.'), env }) + '\n'); + return 0; +} + async function links({ values: v, positionals }, { out, env, io }) { const [sub, ...rest] = positionals; if (!['create', 'list', 'qr'].includes(sub)) return linksCommand(sub, rest, v, {}); diff --git a/src/events.mjs b/src/events.mjs new file mode 100644 index 0000000..44b7655 --- /dev/null +++ b/src/events.mjs @@ -0,0 +1,110 @@ +// `strait events add `: prints ready-to-paste tracking code (ADR 0001). Each snippet is the +// SDK's own call, read from its source on 5 Nov 2026: +// React Native 0.10 links.trackEvent(name, { value, currency, eventId }) src/strait.ts +// Flutter 0.9 links.trackEvent(name, value:, currency:, linkId:, clickId:) lib/src/client.dart (no eventId) +// iOS 0.10 strait.trackEvent(_:value:currency:linkId:clickId:eventId:completion:) StraitLinks.swift +// Android 0.8 client.trackEvent(name, value, currency, linkId, clickId, eventId) StraitClient.kt (blocks) +// Web 0.7 no event helper: the same POST /v1/event as sdk-web/examples/web/src/main.ts +// `links` / `StraitLinks.client` are the names `strait init` writes (templates.mjs). +import { existsSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { detectProject } from './detect.mjs'; +import { UsageError, normalizeHost } from './validate.mjs'; + +export const PLATFORMS = ['react-native', 'flutter', 'ios', 'android', 'web']; +const LABEL = { 'react-native': 'React Native', flutter: 'Flutter', ios: 'iOS (Swift)', android: 'Android (Kotlin)', web: 'Web' }; +// The engine takes any name up to 64 characters; this subset can't break or inject into the printed code. +const NAME = /^[A-Za-z0-9_.:-](?:[A-Za-z0-9 _.:-]{0,62}[A-Za-z0-9_.:-])?$/; + +/** The platform for a folder from its files, or null. Expo uses the React Native SDK. */ +export function detectPlatform(dir) { + const p = detectProject(dir); + if (p) return { platform: p.framework === 'expo' ? 'react-native' : p.framework, sdkInstalled: p.sdkInstalled }; + let names = []; + try { + names = readdirSync(dir); + } catch { + return null; + } + if (names.includes('Package.swift') || names.some((n) => n.endsWith('.xcodeproj') || n.endsWith('.xcworkspace'))) return { platform: 'ios', sdkInstalled: null }; + if (existsSync(join(dir, 'package.json'))) return { platform: 'web', sdkInstalled: null }; + return null; +} + +/** @returns {string} what to print */ +export function eventsCommand(sub, positionals, v, { dir, env }) { + const usage = 'Usage: strait events add [dir] [--platform react-native|flutter|ios|android|web]'; + if (sub !== 'add') throw new UsageError(`${usage}${sub ? ` ("${sub}" is not a subcommand)` : ''}`); + const [name] = positionals; + if (name === undefined) throw new UsageError(usage); + if (!NAME.test(name)) { + throw new UsageError('That event name would break the code. Use 1-64 letters, numbers, spaces and _ . : - (for example purchase or sign_up).'); + } + let platform = v.platform?.trim().toLowerCase(); + let sdkInstalled = null; + if (platform && !PLATFORMS.includes(platform)) throw new UsageError(`--platform is one of ${PLATFORMS.join(', ')}, not "${v.platform}".`); + if (!platform) { + const found = detectPlatform(dir); + if (!found) throw new UsageError(`No app found in ${dir}. Pass --platform react-native|flutter|ios|android|web.`); + ({ platform, sdkInstalled } = found); + } + // Only values that can't break the quoted string go into the code; anything else stays a placeholder. + const envKey = String(env.STRAIT_PUBLISHABLE_KEY ?? '').trim(); + const key = /^st_pub_(live|test)_[A-Za-z0-9]{8,64}$/.test(envKey) ? envKey : 'st_pub_live_…'; + let host = '.strait.link'; + try { + if (env.STRAIT_LINK_HOST) host = normalizeHost(env.STRAIT_LINK_HOST); + } catch { + // keep the placeholder + } + const out = [`"${name}" event for ${LABEL[platform]}${v.platform ? '' : ' (detected)'}. Paste it where the action happens:`, '', SNIPPETS[platform](name, { key, host }).trimEnd(), '']; + if (sdkInstalled === false) out.push('The Strait SDK is not in this project yet: run strait init first, it writes the client this code uses.'); + out.push('Events show in Dashboard → Analytics, on the tap that brought the user (for 7 days).'); + return out.join('\n'); +} + +const SNIPPETS = { + 'react-native': (n) => `import { links } from './links'; // the client strait init wrote; fix the path for this file + +// Resolves true when Strait took it; never throws. For revenue add +// { value: 49.99, currency: 'USD', eventId: order.id } (eventId: a retry is counted once). +await links.trackEvent('${n}'); +`, + flutter: (n) => `import 'links.dart'; // lib/links.dart, the client strait init wrote + +// Resolves true when Strait took it; never throws. For revenue add +// value: 49.99, currency: 'USD' +await links.trackEvent('${n}'); +`, + ios: (n) => `// strait: your StraitLinks instance (strait-sdk-swift README, "Use"). Never throws. +// For revenue add value: 49.99, currency: "USD", eventId: order.id (a retry is counted once). +strait.trackEvent("${n}") { ok in + // ok == false: not sent now; kept and sent later when it was the network +} +`, + android: (n) => `// It sends on the calling thread: call it off the main thread. Never throws. +// For revenue add value = 49.99, currency = "USD", eventId = order.id (a retry is counted once). +val ok = StraitLinks.client.trackEvent("${n}") +`, + web: (n, { key, host }) => `import { reportWebOpen } from '@straitlink/web'; + +const strait = { publishableKey: '${key}', endpoint: 'https://${host}' }; + +// On page load: remember the tap this visit came from. +const open = await reportWebOpen(strait); +if (open.clickId) localStorage.setItem('strait_click', open.clickId); + +// Where "${n}" happens. @straitlink/web has no event helper yet, so this is the same +// POST /v1/event the app SDKs send. For revenue add value: 49.99, currency: 'USD', eventId: order.id +await fetch(\`\${strait.endpoint}/v1/event\`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + publishableKey: strait.publishableKey, + event: '${n}', + platform: 'web', + clickId: localStorage.getItem('strait_click') ?? undefined, + }), +}); +`, +}; diff --git a/src/selftest.mjs b/src/selftest.mjs new file mode 100644 index 0000000..ee2f0e9 --- /dev/null +++ b/src/selftest.mjs @@ -0,0 +1,86 @@ +// `strait test`: one pass for "am I set up right?" (ADR 0001). The engine answers (GET /healthz), +// the secret key is valid and says which workspace (GET /v1/workspace), and the workspace's link +// domain serves apple-app-site-association and assetlinks.json (the same doctor as `strait doctor`). +// Every failure is a `fail` line, never a throw; later checks that depend on it are `skip`. +import { ApiError, redact } from './links.mjs'; +import { normalizeHost } from './validate.mjs'; +import { runDoctor } from './doctor.mjs'; + +const pass = (id, label, title) => ({ id, label, status: 'pass', title, fix: '' }); +const fail = (id, label, title, fix = '') => ({ id, label, status: 'fail', title, fix }); +const skip = (id, label, why) => ({ id, label, status: 'skip', title: why, fix: '' }); + +const DOMAIN_CHECKS = [['domain', 'Link domain'], ['ios', 'iPhone'], ['android', 'Android']]; + +/** + * @param {{ call: Function, apiUrl: string, key: string, fetch: typeof fetch }} opts + * @returns {Promise<{ ok: boolean, workspace?: object, checks: object[] }>} + */ +export async function runSelfTest({ call, apiUrl, key, fetch: fetchImpl }) { + const checks = []; + let workspace; + const done = () => ({ ok: !checks.some((c) => c.status === 'fail'), ...(workspace ? { workspace } : {}), checks }); + + try { + const h = await (await call('GET', '/healthz')).json().catch(() => ({})); + if (h?.ok !== true) throw new ApiError(`${apiUrl} answered, but not like a Strait engine`); + checks.push(pass('engine', 'Engine', `${apiUrl} answers${h.version ? ` (version ${h.version})` : ''}`)); + } catch (e) { + if (!(e instanceof ApiError)) throw e; + checks.push(fail('engine', 'Engine', e.message, 'Check your connection and --api-url, or https://straitlink.in/platform-status/.')); + checks.push(skip('key', 'Key', 'needs the engine'), ...DOMAIN_CHECKS.map(([id, l]) => skip(id, l, 'needs the engine'))); + return done(); + } + + const mode = key.startsWith('st_test_') ? 'test' : 'live'; + try { + const w = await (await call('GET', '/v1/workspace')).json().catch(() => null); + if (typeof w?.linkDomain !== 'string' || !w.linkDomain) throw new ApiError("GET /v1/workspace: the engine's answer has no link domain"); + // Only strings are shown; the prefix is cut to the 12 characters the engine sends, in case a server echoes more. + const str = (x) => (typeof x === 'string' ? x : null); + const prefix = str(w.key?.prefix) && redact(w.key.prefix, key).slice(0, 12); + workspace = { + name: str(w.name), handle: str(w.handle), linkDomain: w.linkDomain, + customDomains: Array.isArray(w.customDomains) ? w.customDomains.filter((d) => typeof d === 'string') : [], + plan: str(w.plan), mode, keyPrefix: prefix || null, keyScope: str(w.key?.scope), + }; + const access = workspace.keyScope && workspace.keyScope !== 'full' ? `, ${workspace.keyScope} access` : ''; + checks.push(pass('key', 'Key', `valid ${mode} key${prefix ? ` ${prefix}…` : ''} for workspace "${workspace.name ?? '?'}"${access}`)); + } catch (e) { + if (!(e instanceof ApiError)) throw e; + checks.push(fail('key', 'Key', e.message, 'Create a secret key in Dashboard → Settings → Secret keys and set STRAIT_SECRET_KEY.')); + checks.push(...DOMAIN_CHECKS.map(([id, l]) => skip(id, l, 'needs a valid key'))); + return done(); + } + + let host; + try { + host = normalizeHost(workspace.linkDomain); + } catch { + checks.push(fail('domain', 'Link domain', `"${workspace.linkDomain}" is not a host name`, 'Contact support@straitlink.in: the engine sent a bad link domain.')); + checks.push(skip('ios', 'iPhone', 'needs a link domain'), skip('android', 'Android', 'needs a link domain')); + return done(); + } + const others = workspace.customDomains.filter((d) => d !== host); + checks.push(pass('domain', 'Link domain', `${host}${workspace.plan ? ` (plan ${workspace.plan})` : ''}${others.length ? `; also ${others.join(', ')}` : ''}`)); + const doc = await runDoctor({ host, fetch: fetchImpl }); + for (const [id, label] of [['ios', 'iPhone'], ['android', 'Android']]) { + const f = doc[id].findings.find((x) => x.severity === 'error') ?? doc[id].findings[0]; + checks.push(f.severity === 'error' ? fail(id, label, f.title, f.fix) : pass(id, label, f.title)); + } + return done(); +} + +/** The readable list: one line per check, its fix under a failure, then the verdict. */ +export function formatSelfTest(result) { + const lines = ['Strait test', '']; + for (const c of result.checks) { + lines.push(` ${c.status} ${c.label.padEnd(12)} ${c.title}`); + if (c.fix) lines.push(`${' '.repeat(21)}Fix: ${c.fix}`); + } + const failed = result.checks.filter((c) => c.status === 'fail').length; + lines.push('', failed ? `${failed} check${failed > 1 ? 's' : ''} failed.` : 'All checks passed.'); + if (!failed) lines.push('Your app\'s own setup (intent filters, Associated Domains): strait doctor --android --ios .'); + // Workspace names and served files are other people's text: no terminal control characters. + return lines.join('\n').replace(/[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/g, ''); +} diff --git a/test/events.test.mjs b/test/events.test.mjs new file mode 100644 index 0000000..551eeae --- /dev/null +++ b/test/events.test.mjs @@ -0,0 +1,112 @@ +// `strait events add `: ready-to-paste tracking code, each SDK's real event call. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { main } from '../src/cli.mjs'; +import { FIXTURES, sink } from './helpers.mjs'; + +const run = async (args, { env = {} } = {}) => { + const stdout = sink(); + const stderr = sink(); + const code = await main(['events', ...args], { stdout, stderr, env, isTTY: false }); + return { code, out: stdout.text, err: stderr.text }; +}; +const dirWith = (files) => { + const d = mkdtempSync(join(tmpdir(), 'strait-events-')); + for (const [p, c] of Object.entries(files)) { + mkdirSync(join(d, p, '..'), { recursive: true }); + writeFileSync(join(d, p), c); + } + return d; +}; + +test('each platform prints its SDK\'s real event call with the name', async () => { + const want = { + 'react-native': [/import \{ links \} from '\.\/links'/, /await links\.trackEvent\('purchase'\);/, /value: 49\.99, currency: 'USD', eventId: order\.id/], + flutter: [/import 'links\.dart';/, /await links\.trackEvent\('purchase'\);/, /value: 49\.99, currency: 'USD'/], + ios: [/strait\.trackEvent\("purchase"\) \{ ok in/, /eventId: order\.id/], + android: [/StraitLinks\.client\.trackEvent\("purchase"\)/, /off the main thread/], + web: [/import \{ reportWebOpen \} from '@straitlink\/web'/, /\/v1\/event/, /event: 'purchase'/, /platform: 'web'/, /clickId/], + }; + for (const [platform, patterns] of Object.entries(want)) { + const r = await run(['add', 'purchase', '--platform', platform, FIXTURES]); + assert.equal(r.code, 0, `${platform}: ${r.err}`); + for (const p of patterns) assert.match(r.out, p, platform); + } +}); + +test('Flutter has no eventId parameter, so its snippet never shows one', async () => { + const r = await run(['add', 'purchase', '--platform', 'flutter', FIXTURES]); + assert.doesNotMatch(r.out, /eventId/); +}); + +test('detects the platform from the project files', async () => { + const cases = { + 'rn-bare': 'React Native', + 'expo-js': 'React Native', + 'flutter-app': 'Flutter', + 'android-kotlin': 'Android', + }; + for (const [fixture, label] of Object.entries(cases)) { + const r = await run(['add', 'signup', join(FIXTURES, fixture)]); + assert.equal(r.code, 0, `${fixture}: ${r.err}`); + assert.match(r.out, new RegExp(`signup.*${label}|${label}.*signup`), fixture); + } + const ios = await run(['add', 'signup', dirWith({ 'Acme.xcodeproj/project.pbxproj': '' })]); + assert.match(ios.out, /strait\.trackEvent\("signup"\)/); + const spm = await run(['add', 'signup', dirWith({ 'Package.swift': '// swift-tools-version:5.9' })]); + assert.match(spm.out, /strait\.trackEvent\("signup"\)/); + const web = await run(['add', 'signup', dirWith({ 'package.json': JSON.stringify({ dependencies: { vite: '5' } }) })]); + assert.match(web.out, /reportWebOpen/); +}); + +test('web fills in the publishable key and link host from the environment when set', async () => { + const r = await run(['add', 'purchase', '--platform', 'web'], { env: { STRAIT_PUBLISHABLE_KEY: 'st_pub_live_0123456789abcdef', STRAIT_LINK_HOST: 'acme.strait.link' } }); + assert.match(r.out, /publishableKey: 'st_pub_live_0123456789abcdef'/); + assert.match(r.out, /endpoint: 'https:\/\/acme\.strait\.link'/); +}); + +test('a project without the SDK gets a hint to run strait init first', async () => { + const r = await run(['add', 'purchase', join(FIXTURES, 'rn-bare')]); + assert.match(r.out, /strait init/); +}); + +test('nothing detected and no --platform: usage error that lists the platforms', async () => { + const r = await run(['add', 'purchase', join(FIXTURES, 'unknown')]); + assert.equal(r.code, 2); + assert.match(r.err, /--platform react-native\|flutter\|ios\|android\|web/); +}); + +test('event names that would break the printed code are refused', async () => { + for (const bad of ["it's", 'a"b', '${x}', 'a\\b', 'x'.repeat(65), ' ']) { + const r = await run(['add', bad, '--platform', 'web']); + assert.equal(r.code, 2, bad); + assert.match(r.err, /event name/i); + } + const ok = await run(['add', 'Sign Up: step-2.v1', '--platform', 'web']); + assert.equal(ok.code, 0, ok.err); +}); + +test('usage errors: missing name, unknown platform, unknown subcommand', async () => { + assert.equal((await run(['add', '--platform', 'web'])).code, 2); + const p = await run(['add', 'x', '--platform', 'unity']); + assert.equal(p.code, 2); + assert.match(p.err, /react-native/); + const s = await run(['remove', 'x']); + assert.equal(s.code, 2); + assert.match(s.err, /strait events add /); +}); + +test('env values that could break the quoted code stay placeholders', async () => { + const r = await run(['add', 'purchase', '--platform', 'web'], { env: { STRAIT_PUBLISHABLE_KEY: "st_pub_live_abc'+alert(1)+'", STRAIT_LINK_HOST: "x.com'; alert(1); '" } }); + assert.equal(r.code, 0); + assert.match(r.out, /publishableKey: 'st_pub_live_…'/); + assert.match(r.out, /endpoint: 'https:\/\/\.strait\.link'/); + assert.doesNotMatch(r.out, /alert/); +}); + +test('too many arguments is a usage error', async () => { + assert.equal((await run(['add', 'purchase', '.', 'extra', '--platform', 'web'])).code, 2); +}); diff --git a/test/selftest.test.mjs b/test/selftest.test.mjs new file mode 100644 index 0000000..0ef96a5 --- /dev/null +++ b/test/selftest.test.mjs @@ -0,0 +1,140 @@ +// `strait test`: key, engine, link domain and its well-known files in one pass/fail list. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { main } from '../src/cli.mjs'; +import { sink } from './helpers.mjs'; + +const SECRET = 'st_live_0123456789abcdef0123456789abcdef'; +const WORKSPACE = { name: 'Acme', handle: 'acme', linkBase: 'https://acme.strait.link', linkDomain: 'acme.strait.link', customDomains: [], plan: 'starter', key: { scope: 'full', prefix: 'st_live_0123' } }; +const AASA = JSON.stringify({ applinks: { details: [{ appIDs: ['TEAM123.com.acme.shop'], components: [{ '/': '/*' }] }] } }); +const ASSETLINKS = JSON.stringify([{ relation: ['delegate_permission/common.handle_all_urls'], target: { namespace: 'android_app', package_name: 'com.acme.shop', sha256_cert_fingerprints: ['AA:BB'] } }]); + +/** A fetch for the engine and the link domain; `over` replaces any route's answer (a function, or 'down'). */ +function world(over = {}) { + const routes = { + '/healthz': () => Response.json({ ok: true, version: 'ddca50a' }), + '/v1/workspace': () => Response.json(WORKSPACE), + '/.well-known/apple-app-site-association': () => new Response(AASA), + '/.well-known/assetlinks.json': () => new Response(ASSETLINKS), + ...over, + }; + const calls = []; + const fn = async (url, init = {}) => { + const u = new URL(String(url)); + calls.push({ url: u, headers: init.headers ?? {} }); + const r = routes[u.pathname]; + if (!r || r === 'down') throw new TypeError('fetch failed'); + return r(u); + }; + fn.calls = calls; + return fn; +} + +const run = async (args = [], { env = { STRAIT_SECRET_KEY: SECRET }, fetch = world() } = {}) => { + const stdout = sink(); + const stderr = sink(); + const code = await main(['test', ...args], { stdout, stderr, env, isTTY: false, fetch }); + assert.ok(!stdout.text.includes(SECRET) && !stderr.text.includes(SECRET), 'the secret key is never printed'); + return { code, out: stdout.text, err: stderr.text, fetch }; +}; + +test('all good: one pass line each for engine, key, workspace domain, iPhone and Android files; exit 0', async () => { + const r = await run(); + assert.equal(r.code, 0, r.out + r.err); + assert.match(r.out, /pass\s+Engine\s+.*strait\.link.*ddca50a/); + assert.match(r.out, /pass\s+Key\s+.*live key.*Acme/); + assert.match(r.out, /pass\s+Link domain\s+acme\.strait\.link/); + assert.match(r.out, /pass\s+iPhone\s+.*TEAM123\.com\.acme\.shop/); + assert.match(r.out, /pass\s+Android\s+.*com\.acme\.shop/); + assert.match(r.out, /All checks passed/); + const ws = r.fetch.calls.find((c) => c.url.pathname === '/v1/workspace'); + assert.equal(ws.url.origin, 'https://strait.link'); + assert.equal(ws.headers.authorization, `Bearer ${SECRET}`); + // The well-known files are read from the workspace's link domain, not the API host. + assert.ok(r.fetch.calls.filter((c) => c.url.pathname.startsWith('/.well-known/')).every((c) => c.url.host === 'acme.strait.link')); +}); + +test('a test key is reported as a test key', async () => { + const key = 'st_test_0123456789abcdef0123456789abcdef'; + const r = await run([], { env: { STRAIT_SECRET_KEY: key } }); + assert.equal(r.code, 0); + assert.match(r.out, /pass\s+Key\s+.*test key/); +}); + +test('a key the engine refuses: Key fails with the fix, domain checks are skipped, exit 1', async () => { + const r = await run([], { fetch: world({ '/v1/workspace': () => Response.json({ error: 'invalid API key' }, { status: 401 }) }) }); + assert.equal(r.code, 1); + assert.match(r.out, /fail\s+Key\s+.*invalid API key/); + assert.match(r.out, /Fix: .*Dashboard → Settings → Secret keys/); + assert.match(r.out, /skip\s+Link domain/); + assert.ok(!r.fetch.calls.some((c) => c.url.pathname.startsWith('/.well-known/'))); +}); + +test('engine unreachable: Engine fails, the rest is skipped, exit 1', async () => { + const r = await run([], { fetch: world({ '/healthz': 'down', '/v1/workspace': 'down' }) }); + assert.equal(r.code, 1); + assert.match(r.out, /fail\s+Engine\s+.*Can't reach https:\/\/strait\.link/); + assert.match(r.out, /skip\s+Key/); + assert.equal(r.fetch.calls.length, 1); +}); + +test('the link domain serves no AASA: iPhone fails with the doctor fix, Android still passes, exit 1', async () => { + const r = await run([], { fetch: world({ '/.well-known/apple-app-site-association': () => new Response('nope', { status: 404 }) }) }); + assert.equal(r.code, 1); + assert.match(r.out, /fail\s+iPhone\s+.*doesn't serve apple-app-site-association/); + assert.match(r.out, /pass\s+Android/); + assert.match(r.out, /1 check failed/); +}); + +test('--json prints { ok, checks } for CI; --api-url picks the engine', async () => { + const r = await run(['--json', '--api-url', 'https://dev.example.test']); + const j = JSON.parse(r.out); + assert.equal(j.ok, true); + assert.deepEqual(j.checks.map((c) => [c.id, c.status]), [['engine', 'pass'], ['key', 'pass'], ['domain', 'pass'], ['ios', 'pass'], ['android', 'pass']]); + assert.equal(j.workspace.linkDomain, 'acme.strait.link'); + assert.equal(j.workspace.mode, 'live'); + assert.equal(r.fetch.calls[0].url.origin, 'https://dev.example.test'); +}); + +test('no key, or a publishable key: usage error (exit 2) that says where to get the secret key, nothing sent', async () => { + const none = await run([], { env: {} }); + assert.equal(none.code, 2); + assert.match(none.err, /Dashboard → Settings → Secret keys/); + const pub = await run([], { env: { STRAIT_SECRET_KEY: 'st_pub_live_0123456789abcdef' } }); + assert.equal(pub.code, 2); + assert.match(pub.err, /publishable key/); + assert.equal(none.fetch.calls.length + pub.fetch.calls.length, 0); +}); + +test('an answer that is not a Strait engine, or a workspace without a link domain, is a fail line, not a crash', async () => { + const html = await run([], { fetch: world({ '/healthz': () => new Response('wifi login') }) }); + assert.equal(html.code, 1); + assert.match(html.out, /fail\s+Engine\s+.*not like a Strait engine/); + const odd = await run([], { fetch: world({ '/v1/workspace': () => new Response('') }) }); + assert.equal(odd.code, 1); + assert.match(odd.out, /fail\s+Key\s+.*no link domain/); +}); + +test('terminal control characters in a workspace name are not printed', async () => { + const r = await run([], { fetch: world({ '/v1/workspace': () => Response.json({ ...WORKSPACE, name: 'Acme\u001b[2J\u001b]0;pwned\u0007' }) }) }); + assert.equal(r.code, 0); + assert.doesNotMatch(r.out, /[\u001b\u0007]/); +}); + +test('odd workspace fields: no crash, a bad link domain is a fail line, the key prefix never grows past 12', async () => { + const odd = await run([], { fetch: world({ '/v1/workspace': () => Response.json({ ...WORKSPACE, customDomains: 'a.strait.link', name: { x: 1 } }) }) }); + assert.equal(odd.code, 0, odd.out + odd.err); + const bad = await run([], { fetch: world({ '/v1/workspace': () => Response.json({ ...WORKSPACE, linkDomain: 'https://a.strait.link/x' }) }) }); + assert.equal(bad.code, 0, 'normalised to a.strait.link'); + const worse = await run([], { fetch: world({ '/v1/workspace': () => Response.json({ ...WORKSPACE, linkDomain: 'not a host' }) }) }); + assert.equal(worse.code, 1); + assert.match(worse.out, /fail\s+Link domain/); + assert.ok(!worse.fetch.calls.some((c) => c.url.pathname.startsWith('/.well-known/'))); + const long = await run(['--json'], { fetch: world({ '/v1/workspace': () => Response.json({ ...WORKSPACE, key: { scope: 'full', prefix: SECRET } }) }) }); + assert.equal(JSON.parse(long.out).workspace.keyPrefix.length <= 12, true); +}); + +test('strait test takes no arguments', async () => { + const r = await run(['extra']); + assert.equal(r.code, 2); +});