@straitlink/cli 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,6 +15,8 @@ One command sets up Strait deep links in your app. Every link open and install i
15
15
  npx @straitlink/cli init # set up deep links in your app
16
16
  npx @straitlink/cli doctor --host acme.strait.link # check the link host lists your app
17
17
  npx @straitlink/cli links create summer https://acme.com/s # create a link (secret key in env)
18
+ npx @straitlink/cli test # key, engine and link domain: pass/fail (secret key in env)
19
+ npx @straitlink/cli events add purchase # print the code that tracks an event
18
20
  ```
19
21
 
20
22
  `strait init` sets up Strait deep links in a React Native, Expo, Flutter or Android (Kotlin) app:
@@ -26,7 +28,7 @@ npx @straitlink/cli links create summer https://acme.com/s # create a link (sec
26
28
  | App | File written |
27
29
  |---|---|
28
30
  | React Native | `links.ts` (or `links.js`) |
29
- | Expo | `links.ts`, plus `app/+native-intent.tsx` with Expo Router |
31
+ | Expo | `links.ts`, plus `app/+native-intent.tsx` with Expo Router (`src/links.ts` and `src/app/+native-intent.tsx` when your routes live in `src/app`) |
30
32
  | Flutter | `lib/links.dart` |
31
33
  | Android | `app/src/main/java/<package>/StraitLinks.kt` |
32
34
 
@@ -96,6 +98,38 @@ These call the [public API](https://straitlink.in/docs/api/) (`POST /v1/links`,
96
98
 
97
99
  Exit codes: `0` done, `1` the API said no or can't be reached (its message on stderr), `2` usage problem.
98
100
 
101
+ ## Am I set up right? `strait test`
102
+
103
+ ```bash
104
+ export STRAIT_SECRET_KEY=st_live_… # Dashboard → Settings → Secret keys
105
+ npx @straitlink/cli test
106
+ ```
107
+
108
+ ```
109
+ Strait test
110
+
111
+ pass Engine https://strait.link answers (version ddca50a)
112
+ pass Key valid live key st_live_3f9a… for workspace "Acme"
113
+ pass Link domain acme.strait.link (plan starter)
114
+ pass iPhone acme.strait.link lists TEAM123.com.acme.shop
115
+ pass Android acme.strait.link lists com.acme.shop
116
+
117
+ All checks passed.
118
+ Your app's own setup (intent filters, Associated Domains): strait doctor --android <package> --ios <bundleId>.
119
+ ```
120
+
121
+ 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.
122
+
123
+ ## Track an event: `strait events add`
124
+
125
+ ```bash
126
+ npx @straitlink/cli events add purchase # platform detected from the project files
127
+ npx @straitlink/cli events add sign_up --platform ios # react-native | flutter | ios | android | web
128
+ npx @straitlink/cli events add purchase ./mobile # [dir]: the app folder, default the current one
129
+ ```
130
+
131
+ 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` on page load plus `trackEvent` from `@straitlink/web`. 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 `_ . : -`.
132
+
99
133
  ## Install lines: one constant
100
134
 
101
135
  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"`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@straitlink/cli",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Strait deep links from the terminal: init sets them up in a React Native, Expo, Flutter or Android app, doctor checks your link host, links creates and lists links and saves QR codes.",
5
5
  "keywords": [
6
6
  "strait",
package/src/cli.mjs CHANGED
@@ -9,6 +9,8 @@ import { resolve } from 'node:path';
9
9
  import { formatDoctor, runDoctor } from './doctor.mjs';
10
10
  import { ApiError, DEFAULT_API_URL, apiClient, linksCommand, secretKey } from './links.mjs';
11
11
  import { CLI_VERSION } from './version.mjs';
12
+ import { formatSelfTest, runSelfTest } from './selftest.mjs';
13
+ import { eventsCommand } from './events.mjs';
12
14
 
13
15
  const OPTIONS = {
14
16
  init: {
@@ -43,6 +45,14 @@ const OPTIONS = {
43
45
  force: { type: 'boolean', default: false },
44
46
  json: { type: 'boolean', default: false },
45
47
  },
48
+ test: {
49
+ 'secret-key': { type: 'string' },
50
+ 'api-url': { type: 'string' },
51
+ json: { type: 'boolean', default: false },
52
+ },
53
+ events: {
54
+ platform: { type: 'string' },
55
+ },
46
56
  };
47
57
 
48
58
  export const HELP = `strait: set up and check Strait deep links, and manage links
@@ -53,6 +63,8 @@ Usage
53
63
  strait links create <slug> <url> [options] Create a link
54
64
  strait links list [options] List links
55
65
  strait links qr <slug> [options] Save a link's QR code
66
+ strait test [options] Check your key, the engine and your link domain
67
+ strait events add <name> [dir] [options] Print ready-to-paste code that tracks an event
56
68
 
57
69
  init options
58
70
  --key <st_pub_live_…> Publishable key (Dashboard → Get started)
@@ -83,6 +95,16 @@ links options
83
95
  --format png|svg, --size <px>, --out <file>, --force qr: output (default <slug>.png)
84
96
  --json Print the API answer as JSON
85
97
 
98
+ test options
99
+ --secret-key, --api-url As for links (env STRAIT_SECRET_KEY, STRAIT_API_URL)
100
+ --json Print { ok, workspace, checks } as JSON
101
+ Checks the engine answers, the key is valid (which workspace, test or live), and the
102
+ workspace's link domain serves apple-app-site-association and assetlinks.json. Exit 1
103
+ when a check fails (so it can run in CI), 2 for a missing or wrong kind of key.
104
+
105
+ events options
106
+ --platform <name> react-native | flutter | ios | android | web (detected by default)
107
+
86
108
  Environment
87
109
  STRAIT_PUBLISHABLE_KEY, STRAIT_LINK_HOST, STRAIT_SECRET_KEY, STRAIT_API_URL
88
110
 
@@ -91,6 +113,8 @@ Examples
91
113
  npx @straitlink/cli init --key st_pub_live_… --host acme.strait.link --dry-run
92
114
  npx @straitlink/cli doctor --host acme.strait.link --android com.acme.shop --ios com.acme.shop
93
115
  STRAIT_SECRET_KEY=st_live_… npx @straitlink/cli links create summer https://acme.com/summer
116
+ STRAIT_SECRET_KEY=st_live_… npx @straitlink/cli test
117
+ npx @straitlink/cli events add purchase
94
118
  `;
95
119
 
96
120
  /**
@@ -122,7 +146,7 @@ export async function main(argv, io = {}) {
122
146
  }
123
147
  if (cmd !== 'init') {
124
148
  try {
125
- return await (cmd === 'doctor' ? doctor : links)(args, { out, err, env, io });
149
+ return await { doctor, links, test: selfTest, events }[cmd](args, { out, err, env, io });
126
150
  } catch (e) {
127
151
  if (e instanceof UsageError) {
128
152
  err.write(`strait: ${e.message}\n`);
@@ -208,6 +232,24 @@ async function doctor({ values: v }, { out, env, io }) {
208
232
  return result.ok ? 0 : 1;
209
233
  }
210
234
 
235
+ async function selfTest({ values: v, positionals }, { out, env, io }) {
236
+ if (positionals.length) throw new UsageError(`strait test takes no arguments ("${positionals[0]}"). Options: --secret-key, --api-url, --json.`);
237
+ const key = secretKey(v['secret-key'] ?? env.STRAIT_SECRET_KEY);
238
+ const apiUrl = String(v['api-url'] || env.STRAIT_API_URL || DEFAULT_API_URL).replace(/\/+$/, '');
239
+ const fetchImpl = io.fetch ?? fetch;
240
+ const call = apiClient({ apiUrl, key, fetch: fetchImpl, userAgent: `strait-cli/${CLI_VERSION}` });
241
+ const result = await runSelfTest({ call, apiUrl, key, fetch: fetchImpl });
242
+ out.write((v.json ? JSON.stringify(result, null, 2) : formatSelfTest(result)) + '\n');
243
+ return result.ok ? 0 : 1;
244
+ }
245
+
246
+ async function events({ values: v, positionals }, { out, env, io }) {
247
+ const [sub, name, dirArg, extra] = positionals;
248
+ if (extra !== undefined) throw new UsageError(`Too many arguments ("${extra}"). Usage: strait events add <name> [dir] [--platform …]`);
249
+ out.write(eventsCommand(sub, name === undefined ? [] : [name], v, { dir: resolve(io.cwd ?? process.cwd(), dirArg ?? '.'), env }) + '\n');
250
+ return 0;
251
+ }
252
+
211
253
  async function links({ values: v, positionals }, { out, env, io }) {
212
254
  const [sub, ...rest] = positionals;
213
255
  if (!['create', 'list', 'qr'].includes(sub)) return linksCommand(sub, rest, v, {});
package/src/detect.mjs CHANGED
@@ -73,6 +73,8 @@ export function detectProject(dir) {
73
73
  framework: 'expo',
74
74
  typescript: existsSync(join(dir, 'tsconfig.json')),
75
75
  expoRouter: Boolean(deps['expo-router']),
76
+ // Expo Router's root: src/app when it exists, else app (@expo/metro-config getRouterDirectory).
77
+ routerDir: deps['expo-router'] && existsSync(join(dir, 'src', 'app')) ? 'src/app' : 'app',
76
78
  appName: expo.name ?? pkg.name ?? null,
77
79
  androidPackage: expo.android?.package ?? null,
78
80
  iosBundleId: expo.ios?.bundleIdentifier ?? null,
package/src/events.mjs ADDED
@@ -0,0 +1,105 @@
1
+ // `strait events add <name>`: prints ready-to-paste tracking code (ADR 0001). Each snippet is the
2
+ // SDK's own call, read from its source on 5 Nov 2026:
3
+ // React Native 0.11 links.trackEvent(name, { value, currency, eventId }) src/strait.ts (0.10.2: no eventId)
4
+ // Flutter 0.10 links.trackEvent(name, value:, currency:, linkId:, clickId:, eventId:) lib/src/client.dart
5
+ // iOS 0.10 strait.trackEvent(_:value:currency:linkId:clickId:eventId:completion:) StraitLinks.swift
6
+ // Android 0.8 client.trackEvent(name, value, currency, linkId, clickId, eventId) StraitClient.kt (blocks)
7
+ // Web (Unreleased) trackEvent(config, name, { value, currency, eventId }) src/index.ts (sdk-web ADR 0002)
8
+ // `links` / `StraitLinks.client` are the names `strait init` writes (templates.mjs).
9
+ import { existsSync, readdirSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { detectProject } from './detect.mjs';
12
+ import { PACKAGES, VERSIONS } from './install.mjs';
13
+ import { UsageError, normalizeHost } from './validate.mjs';
14
+
15
+ export const PLATFORMS = ['react-native', 'flutter', 'ios', 'android', 'web'];
16
+ const LABEL = { 'react-native': 'React Native', flutter: 'Flutter', ios: 'iOS (Swift)', android: 'Android (Kotlin)', web: 'Web' };
17
+ // The engine takes any name up to 64 characters; this subset can't break or inject into the printed code.
18
+ const NAME = /^[A-Za-z0-9_.:-](?:[A-Za-z0-9 _.:-]{0,62}[A-Za-z0-9_.:-])?$/;
19
+
20
+ /** The platform for a folder from its files, or null. Expo uses the React Native SDK. */
21
+ export function detectPlatform(dir) {
22
+ const p = detectProject(dir);
23
+ if (p) return { platform: p.framework === 'expo' ? 'react-native' : p.framework, sdkInstalled: p.sdkInstalled };
24
+ let names = [];
25
+ try {
26
+ names = readdirSync(dir);
27
+ } catch {
28
+ return null;
29
+ }
30
+ if (names.includes('Package.swift') || names.some((n) => n.endsWith('.xcodeproj') || n.endsWith('.xcworkspace'))) return { platform: 'ios', sdkInstalled: null };
31
+ if (existsSync(join(dir, 'package.json'))) return { platform: 'web', sdkInstalled: null };
32
+ return null;
33
+ }
34
+
35
+ /** @returns {string} what to print */
36
+ export function eventsCommand(sub, positionals, v, { dir, env }) {
37
+ const usage = 'Usage: strait events add <name> [dir] [--platform react-native|flutter|ios|android|web]';
38
+ if (sub !== 'add') throw new UsageError(`${usage}${sub ? ` ("${sub}" is not a subcommand)` : ''}`);
39
+ const [name] = positionals;
40
+ if (name === undefined) throw new UsageError(usage);
41
+ if (!NAME.test(name)) {
42
+ throw new UsageError('That event name would break the code. Use 1-64 letters, numbers, spaces and _ . : - (for example purchase or sign_up).');
43
+ }
44
+ let platform = v.platform?.trim().toLowerCase();
45
+ let sdkInstalled = null;
46
+ if (platform && !PLATFORMS.includes(platform)) throw new UsageError(`--platform is one of ${PLATFORMS.join(', ')}, not "${v.platform}".`);
47
+ if (!platform) {
48
+ const found = detectPlatform(dir);
49
+ if (!found) throw new UsageError(`No app found in ${dir}. Pass --platform react-native|flutter|ios|android|web.`);
50
+ ({ platform, sdkInstalled } = found);
51
+ }
52
+ // Only values that can't break the quoted string go into the code; anything else stays a placeholder.
53
+ const envKey = String(env.STRAIT_PUBLISHABLE_KEY ?? '').trim();
54
+ const key = /^st_pub_(live|test)_[A-Za-z0-9]{8,64}$/.test(envKey) ? envKey : 'st_pub_live_…';
55
+ let host = '<your-handle>.strait.link';
56
+ try {
57
+ if (env.STRAIT_LINK_HOST) host = normalizeHost(env.STRAIT_LINK_HOST);
58
+ } catch {
59
+ // keep the placeholder
60
+ }
61
+ const out = [`"${name}" event for ${LABEL[platform]}${v.platform ? '' : ' (detected)'}. Paste it where the action happens:`, '', SNIPPETS[platform](name, { key, host }).trimEnd(), ''];
62
+ 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.');
63
+ out.push('Events show in Dashboard → Analytics, on the tap that brought the user (for 7 days).');
64
+ return out.join('\n');
65
+ }
66
+
67
+ const SNIPPETS = {
68
+ 'react-native': (n) => `import { links } from './links'; // the client strait init wrote; fix the path for this file
69
+
70
+ // Resolves true when Strait took it; never throws. For revenue add
71
+ // { value: 49.99, currency: 'USD', eventId: order.id } (eventId: a retry is counted once;
72
+ // eventId needs ${PACKAGES.reactNative} 0.11.0+).
73
+ await links.trackEvent('${n}');
74
+ `,
75
+ flutter: (n) => `import 'links.dart'; // lib/links.dart, the client strait init wrote
76
+
77
+ // Resolves true when Strait took it; never throws. For revenue add
78
+ // value: 49.99, currency: 'USD', eventId: order.id (eventId: a retry is counted once;
79
+ // needs ${PACKAGES.flutter} 0.10.0+).
80
+ await links.trackEvent('${n}');
81
+ `,
82
+ ios: (n) => `// strait: your StraitLinks instance (strait-sdk-swift README, "Use"). Never throws.
83
+ // For revenue add value: 49.99, currency: "USD", eventId: order.id (a retry is counted once;
84
+ // eventId needs StraitSDK ${VERSIONS.version}+: SwiftPM from: "${VERSIONS.version}").
85
+ strait.trackEvent("${n}") { ok in
86
+ // ok == false: not sent now; kept and sent later when it was the network
87
+ }
88
+ `,
89
+ android: (n) => `// It sends on the calling thread: call it off the main thread. Never throws.
90
+ // For revenue add value = 49.99, currency = "USD", eventId = order.id (a retry is counted once;
91
+ // eventId needs ${PACKAGES.android} ${VERSIONS.androidVersion}+, what strait init installs).
92
+ val ok = StraitLinks.client.trackEvent("${n}")
93
+ `,
94
+ web: (n, { key, host }) => `import { reportWebOpen, trackEvent } from '@straitlink/web';
95
+
96
+ const strait = { publishableKey: '${key}', endpoint: 'https://${host}' };
97
+
98
+ // On page load: counts the open and remembers the tap this visit came from.
99
+ await reportWebOpen(strait);
100
+
101
+ // Where "${n}" happens. Resolves true when Strait took it; never throws. For revenue add
102
+ // { value: 49.99, currency: 'USD', eventId: order.id } (eventId: a repeat is counted once).
103
+ await trackEvent(strait, '${n}');
104
+ `,
105
+ };
package/src/install.mjs CHANGED
@@ -19,12 +19,13 @@ export const sourceFor = (src, registry) => (typeof src.source === 'string' ? sr
19
19
  /**
20
20
  * SDK versions the generated code is written for. Must equal site/site.config.json `packages`
21
21
  * (the one source of truth; test/install.test.mjs fails if they differ). Bump both together.
22
+ * `strait events add` reads them too: its iOS/Android eventId needs these SDKs.
22
23
  */
23
24
  export const VERSIONS = {
24
- version: '0.9.5', // Swift (Unity is not written by this CLI)
25
+ version: '0.11.0', // Swift (Unity is not written by this CLI)
25
26
  rnVersion: '0.10.2',
26
- flutterVersion: '0.9.7',
27
- androidVersion: '0.8.4',
27
+ flutterVersion: '0.10.1',
28
+ androidVersion: '0.10.0',
28
29
  };
29
30
 
30
31
  export const PACKAGES = {
@@ -0,0 +1,86 @@
1
+ // `strait test`: one pass for "am I set up right?" (ADR 0001). The engine answers (GET /healthz),
2
+ // the secret key is valid and says which workspace (GET /v1/workspace), and the workspace's link
3
+ // domain serves apple-app-site-association and assetlinks.json (the same doctor as `strait doctor`).
4
+ // Every failure is a `fail` line, never a throw; later checks that depend on it are `skip`.
5
+ import { ApiError, redact } from './links.mjs';
6
+ import { normalizeHost } from './validate.mjs';
7
+ import { runDoctor } from './doctor.mjs';
8
+
9
+ const pass = (id, label, title) => ({ id, label, status: 'pass', title, fix: '' });
10
+ const fail = (id, label, title, fix = '') => ({ id, label, status: 'fail', title, fix });
11
+ const skip = (id, label, why) => ({ id, label, status: 'skip', title: why, fix: '' });
12
+
13
+ const DOMAIN_CHECKS = [['domain', 'Link domain'], ['ios', 'iPhone'], ['android', 'Android']];
14
+
15
+ /**
16
+ * @param {{ call: Function, apiUrl: string, key: string, fetch: typeof fetch }} opts
17
+ * @returns {Promise<{ ok: boolean, workspace?: object, checks: object[] }>}
18
+ */
19
+ export async function runSelfTest({ call, apiUrl, key, fetch: fetchImpl }) {
20
+ const checks = [];
21
+ let workspace;
22
+ const done = () => ({ ok: !checks.some((c) => c.status === 'fail'), ...(workspace ? { workspace } : {}), checks });
23
+
24
+ try {
25
+ const h = await (await call('GET', '/healthz')).json().catch(() => ({}));
26
+ if (h?.ok !== true) throw new ApiError(`${apiUrl} answered, but not like a Strait engine`);
27
+ checks.push(pass('engine', 'Engine', `${apiUrl} answers${h.version ? ` (version ${h.version})` : ''}`));
28
+ } catch (e) {
29
+ if (!(e instanceof ApiError)) throw e;
30
+ checks.push(fail('engine', 'Engine', e.message, 'Check your connection and --api-url, or https://straitlink.in/platform-status/.'));
31
+ checks.push(skip('key', 'Key', 'needs the engine'), ...DOMAIN_CHECKS.map(([id, l]) => skip(id, l, 'needs the engine')));
32
+ return done();
33
+ }
34
+
35
+ const mode = key.startsWith('st_test_') ? 'test' : 'live';
36
+ try {
37
+ const w = await (await call('GET', '/v1/workspace')).json().catch(() => null);
38
+ if (typeof w?.linkDomain !== 'string' || !w.linkDomain) throw new ApiError("GET /v1/workspace: the engine's answer has no link domain");
39
+ // Only strings are shown; the prefix is cut to the 12 characters the engine sends, in case a server echoes more.
40
+ const str = (x) => (typeof x === 'string' ? x : null);
41
+ const prefix = str(w.key?.prefix) && redact(w.key.prefix, key).slice(0, 12);
42
+ workspace = {
43
+ name: str(w.name), handle: str(w.handle), linkDomain: w.linkDomain,
44
+ customDomains: Array.isArray(w.customDomains) ? w.customDomains.filter((d) => typeof d === 'string') : [],
45
+ plan: str(w.plan), mode, keyPrefix: prefix || null, keyScope: str(w.key?.scope),
46
+ };
47
+ const access = workspace.keyScope && workspace.keyScope !== 'full' ? `, ${workspace.keyScope} access` : '';
48
+ checks.push(pass('key', 'Key', `valid ${mode} key${prefix ? ` ${prefix}…` : ''} for workspace "${workspace.name ?? '?'}"${access}`));
49
+ } catch (e) {
50
+ if (!(e instanceof ApiError)) throw e;
51
+ checks.push(fail('key', 'Key', e.message, 'Create a secret key in Dashboard → Settings → Secret keys and set STRAIT_SECRET_KEY.'));
52
+ checks.push(...DOMAIN_CHECKS.map(([id, l]) => skip(id, l, 'needs a valid key')));
53
+ return done();
54
+ }
55
+
56
+ let host;
57
+ try {
58
+ host = normalizeHost(workspace.linkDomain);
59
+ } catch {
60
+ checks.push(fail('domain', 'Link domain', `"${workspace.linkDomain}" is not a host name`, 'Contact support@straitlink.in: the engine sent a bad link domain.'));
61
+ checks.push(skip('ios', 'iPhone', 'needs a link domain'), skip('android', 'Android', 'needs a link domain'));
62
+ return done();
63
+ }
64
+ const others = workspace.customDomains.filter((d) => d !== host);
65
+ checks.push(pass('domain', 'Link domain', `${host}${workspace.plan ? ` (plan ${workspace.plan})` : ''}${others.length ? `; also ${others.join(', ')}` : ''}`));
66
+ const doc = await runDoctor({ host, fetch: fetchImpl });
67
+ for (const [id, label] of [['ios', 'iPhone'], ['android', 'Android']]) {
68
+ const f = doc[id].findings.find((x) => x.severity === 'error') ?? doc[id].findings[0];
69
+ checks.push(f.severity === 'error' ? fail(id, label, f.title, f.fix) : pass(id, label, f.title));
70
+ }
71
+ return done();
72
+ }
73
+
74
+ /** The readable list: one line per check, its fix under a failure, then the verdict. */
75
+ export function formatSelfTest(result) {
76
+ const lines = ['Strait test', ''];
77
+ for (const c of result.checks) {
78
+ lines.push(` ${c.status} ${c.label.padEnd(12)} ${c.title}`);
79
+ if (c.fix) lines.push(`${' '.repeat(21)}Fix: ${c.fix}`);
80
+ }
81
+ const failed = result.checks.filter((c) => c.status === 'fail').length;
82
+ lines.push('', failed ? `${failed} check${failed > 1 ? 's' : ''} failed.` : 'All checks passed.');
83
+ if (!failed) lines.push('Your app\'s own setup (intent filters, Associated Domains): strait doctor --android <package> --ios <bundleId>.');
84
+ // Workspace names and served files are other people's text: no terminal control characters.
85
+ return lines.join('\n').replace(/[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/g, '');
86
+ }
package/src/templates.mjs CHANGED
@@ -8,7 +8,7 @@ const HEADER = (pkg, version) => `Created by \`strait init\` (${pkg} ${version})
8
8
 
9
9
  // ---------- integration files ----------
10
10
 
11
- function rnLinks({ key, host }, { withRouting }) {
11
+ function rnLinks({ key, host }, { withRouting, routerDir = 'app' }) {
12
12
  return `// ${HEADER(PACKAGES.reactNative, VERSIONS.rnVersion)}
13
13
  // Import this file once from your entry file (index.js) so the launch link is caught.
14
14
  import AsyncStorage from '@react-native-async-storage/async-storage';
@@ -36,7 +36,7 @@ links.onLink((e) => {
36
36
  });
37
37
  `
38
38
  : `
39
- // Routing lives in app/_layout.tsx (links.onLink). This log helps while you set up.
39
+ // Routing lives in ${routerDir}/_layout.tsx (links.onLink). This log helps while you set up.
40
40
  links.onLink((e) => console.log('Strait link:', e.kind, e.route, e.matched, e.path));
41
41
  `
42
42
  }
@@ -44,8 +44,8 @@ links.start();
44
44
  `;
45
45
  }
46
46
 
47
- const NATIVE_INTENT = `// ${HEADER(PACKAGES.reactNative, VERSIONS.rnVersion)}
48
- // Links reach the app through Strait's onLink (app/_layout.tsx), which knows the
47
+ const nativeIntent = (routerDir) => `// ${HEADER(PACKAGES.reactNative, VERSIONS.rnVersion)}
48
+ // Links reach the app through Strait's onLink (${routerDir}/_layout.tsx), which knows the
49
49
  // real destination. Keep Expo Router from routing the raw link URL.
50
50
  // If your app has other deep links of its own (a sign-in callback), return \`path\` for those.
51
51
  export function redirectSystemPath({ path }: { path: string; initial: boolean }) {
@@ -223,8 +223,11 @@ export function integrationFiles(project, opts) {
223
223
  case 'react-native':
224
224
  return [{ path: `links.${ext}`, contents: rnLinks(opts, { withRouting: true }), why: 'creates the client and handles every link' }];
225
225
  case 'expo': {
226
- const files = [{ path: `links.${ext}`, contents: rnLinks(opts, { withRouting: !project.expoRouter }), why: 'creates the client' }];
227
- if (project.expoRouter) files.push({ path: 'app/+native-intent.tsx', contents: NATIVE_INTENT, why: 'stops Expo Router routing the raw link URL' });
226
+ const routerDir = project.routerDir ?? 'app';
227
+ // links.ts sits next to the router folder, so the layout's `../links` import finds it.
228
+ const linksPath = project.expoRouter && routerDir === 'src/app' ? `src/links.${ext}` : `links.${ext}`;
229
+ const files = [{ path: linksPath, contents: rnLinks(opts, { withRouting: !project.expoRouter, routerDir }), why: 'creates the client' }];
230
+ if (project.expoRouter) files.push({ path: `${routerDir}/+native-intent.tsx`, contents: nativeIntent(routerDir), why: 'stops Expo Router routing the raw link URL' });
228
231
  return files;
229
232
  }
230
233
  case 'flutter':
@@ -333,7 +336,7 @@ export function nextSteps(project, opts, installSrc) {
333
336
  if (fw === 'react-native') steps.push({ title: 'Load links at startup', body: `Add \`import './links';\` to the top of index.js, then rebuild (npx react-native run-android). The SDK adds a native module, so a Metro reload is not enough.` });
334
337
  if (fw === 'expo') {
335
338
  steps.push({ title: 'Add link settings to app.json (merge with what you have)', code: expoAppJson(opts, project), lang: 'json', body: `${project.dynamicConfig ? 'Your config is app.config.js/ts, so copy these keys into it by hand. ' : ''}associatedDomains opens links in your iPhone app. ${IPHONE_INSTALLS}` });
336
- if (project.expoRouter) steps.push({ title: 'Route links in app/_layout.tsx', code: EXPO_LAYOUT, lang: 'tsx' });
339
+ if (project.expoRouter) steps.push({ title: `Route links in ${project.routerDir ?? 'app'}/_layout.tsx`, code: EXPO_LAYOUT, lang: 'tsx' });
337
340
  else steps.push({ title: 'Load links at startup', body: "Add `import './links';` to your entry file." });
338
341
  steps.push({ title: 'Make a development build (Expo Go cannot load the native module)', code: 'npx expo run:android # or: eas build --profile development --platform android', lang: 'bash' });
339
342
  }
package/src/version.mjs CHANGED
@@ -1,2 +1,2 @@
1
1
  // Kept equal to package.json version (test/links.test.mjs checks it). Sent as the API User-Agent.
2
- export const CLI_VERSION = '0.2.1';
2
+ export const CLI_VERSION = '0.3.0';