Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 <name>`: 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`.
Expand Down
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 <package> --ios <bundleId>.
```

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"`.
Expand Down
35 changes: 35 additions & 0 deletions docs/adr/0001-strait-test-and-events-add.md
Original file line number Diff line number Diff line change
@@ -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.
44 changes: 43 additions & 1 deletion src/cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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
Expand All @@ -53,6 +63,8 @@ Usage
strait links create <slug> <url> [options] Create a link
strait links list [options] List links
strait links qr <slug> [options] Save a link's QR code
strait test [options] Check your key, the engine and your link domain
strait events add <name> [dir] [options] Print ready-to-paste code that tracks an event

init options
--key <st_pub_live_…> Publishable key (Dashboard → Get started)
Expand Down Expand Up @@ -83,6 +95,16 @@ links options
--format png|svg, --size <px>, --out <file>, --force qr: output (default <slug>.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 <name> react-native | flutter | ios | android | web (detected by default)

Environment
STRAIT_PUBLISHABLE_KEY, STRAIT_LINK_HOST, STRAIT_SECRET_KEY, STRAIT_API_URL

Expand All @@ -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
`;

/**
Expand Down Expand Up @@ -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`);
Expand Down Expand Up @@ -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 <name> [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, {});
Expand Down
110 changes: 110 additions & 0 deletions src/events.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// `strait events add <name>`: 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 <name> [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 = '<your-handle>.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,
}),
});
`,
};
Loading
Loading