@straitlink/cli 0.0.0-stage → 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 The Strait authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,81 @@
1
- # Temporary Holding Version
1
+ <h1 align="center">Strait CLI</h1>
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ <p align="center"><strong>Straight to the screen. On the record.</strong><br>
4
+ One command sets up Strait deep links in your app. Every link open and install is then recorded in your Strait dashboard.</p>
5
+
6
+ <p align="center">
7
+ <a href="https://straitlink.in/docs/">Docs</a> ·
8
+ <a href="https://straitlink.in/platform-status/">Platform status</a> ·
9
+ <a href="#docs-and-support">Talk to the Strait team</a>
10
+ </p>
11
+
12
+ `@straitlink/cli`
13
+
14
+ ```bash
15
+ npx @straitlink/cli init
16
+ ```
17
+
18
+ `strait init` sets up Strait deep links in a React Native, Expo, Flutter or Android (Kotlin) app:
19
+
20
+ 1. Detects the app from its files (`package.json`, `app.json`, `pubspec.yaml`, Gradle files). Nothing is run.
21
+ 2. Takes your publishable key and link host as flags, or asks for them in a terminal. A secret key (`st_live_…`) is refused, because it must never ship in an app.
22
+ 3. Writes the integration file, with your key, host and an `onLink` handler:
23
+
24
+ | App | File written |
25
+ |---|---|
26
+ | React Native | `links.ts` (or `links.js`) |
27
+ | Expo | `links.ts`, plus `app/+native-intent.tsx` with Expo Router |
28
+ | Flutter | `lib/links.dart` |
29
+ | Android | `app/src/main/java/<package>/StraitLinks.kt` |
30
+
31
+ 4. Prints the remaining steps for your app: the install command, the AndroidManifest intent filters (or the `app.json` block for Expo), the iPhone (beta) Associated Domains entry, what to fill in under Dashboard → Settings, and the `curl` checks for `assetlinks.json` and `apple-app-site-association`. Steps the project already has, such as the intent filters, are marked `[already done]`.
32
+
33
+ It never edits your existing files and never overwrites an earlier integration file unless you pass `--force`.
34
+
35
+ ## Use
36
+
37
+ Run it in your app's folder. Node 18.17 or newer; nothing to install first.
38
+
39
+ ```bash
40
+ npx @straitlink/cli init # asks for the key and host
41
+ npx @straitlink/cli init --key st_pub_live_… --host acme.strait.link
42
+ npx @straitlink/cli init ../my-app --dry-run # show the files and steps, write nothing
43
+ ```
44
+
45
+ From a checkout of this repo: `node bin/strait.mjs init ../my-app`, or `npm link && strait init`.
46
+
47
+ | Option | |
48
+ |---|---|
49
+ | `--key` | Publishable key (Dashboard → Get started). Env: `STRAIT_PUBLISHABLE_KEY` |
50
+ | `--host` | Link domain, e.g. `acme.strait.link`. A pasted link works too. Env: `STRAIT_LINK_HOST` |
51
+ | `--scheme` | Custom scheme. Taken from `app.json`, or guessed from the package name (`com.acme.shop` → `acmeshop`) |
52
+ | `--framework` | `react-native`, `expo`, `flutter` or `android`, to skip detection |
53
+ | `--dry-run` | Print the files and steps; write nothing |
54
+ | `--force` | Replace an existing integration file |
55
+ | `--yes`, `-y` | Never prompt. Exits 2 if something is missing (for CI and agents) |
56
+
57
+ Exit codes: `0` done, `2` usage problem (message on stderr), `1` unexpected error.
58
+
59
+ ## Install lines: one constant
60
+
61
+ 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"`.
62
+
63
+ ## Test
64
+
65
+ ```bash
66
+ npm test # node:test, no dependencies
67
+ node scripts/time-to-first-link.mjs # see docs/TIME-TO-FIRST-LINK.md
68
+ ```
69
+
70
+ Fixture apps live in `test/fixtures/` (bare React Native, Expo with and without Expo Router, Flutter, Kotlin, and a folder with no app).
71
+
72
+ ## Docs and support
73
+
74
+ - **Docs:** [straitlink.in/docs/](https://straitlink.in/docs/) · [platform status](https://straitlink.in/platform-status/) · [troubleshooting](https://straitlink.in/docs/troubleshooting/)
75
+ - **Talk to the Strait team:** [support@straitlink.in](mailto:support@straitlink.in) (replies within 1 working day, IST) or call +91 81218 61890.
76
+ - **Bugs and feature requests:** [open an issue](https://github.com/bazingga08/strait-cli/issues) on this repo.
77
+ - **Security:** never in a public issue. Write to security@straitlink.in.
78
+
79
+ ## Licence
80
+
81
+ MIT. See [LICENSE](LICENSE).
package/bin/strait.mjs ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ import { main } from '../src/cli.mjs';
3
+
4
+ main(process.argv.slice(2)).then(
5
+ (code) => process.exit(code),
6
+ (e) => {
7
+ process.stderr.write(`strait: unexpected error: ${e?.stack ?? e}\n`);
8
+ process.exit(1);
9
+ },
10
+ );
package/package.json CHANGED
@@ -1,6 +1,46 @@
1
1
  {
2
2
  "name": "@straitlink/cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "npx @straitlink/cli init: set up Strait deep links in a React Native, Expo, Flutter or Android app.",
5
+ "keywords": [
6
+ "strait",
7
+ "deep-linking",
8
+ "deferred-deep-linking",
9
+ "app-links",
10
+ "universal-links",
11
+ "react-native",
12
+ "expo",
13
+ "flutter",
14
+ "android",
15
+ "cli"
16
+ ],
17
+ "homepage": "https://straitlink.in/docs/",
18
+ "bugs": {
19
+ "url": "https://github.com/bazingga08/strait-cli/issues"
20
+ },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/bazingga08/strait-cli.git"
24
+ },
25
+ "license": "MIT",
26
+ "author": "The Strait team <support@straitlink.in>",
27
+ "type": "module",
28
+ "bin": {
29
+ "strait": "bin/strait.mjs"
30
+ },
31
+ "files": [
32
+ "bin/",
33
+ "src/"
34
+ ],
35
+ "scripts": {
36
+ "test": "node --test test/*.test.mjs",
37
+ "ttfl": "node scripts/time-to-first-link.mjs"
38
+ },
39
+ "engines": {
40
+ "node": ">=18.17"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public",
44
+ "provenance": true
45
+ }
46
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,129 @@
1
+ // Argument parsing and prompting. `main(argv, io)` returns an exit code, so tests
2
+ // can drive it without spawning a process.
3
+ import { createInterface } from 'node:readline/promises';
4
+ import { parseArgs } from 'node:util';
5
+ import { detectProject } from './detect.mjs';
6
+ import { formatPlan, planInit, writeFiles } from './init.mjs';
7
+ import { UsageError, normalizeHost, normalizeKey, normalizeScheme, suggestScheme } from './validate.mjs';
8
+ import { resolve } from 'node:path';
9
+
10
+ export const HELP = `strait: set up Strait deep links in your app
11
+
12
+ Usage
13
+ strait init [dir] [options]
14
+
15
+ Options
16
+ --key <st_pub_live_…> Publishable key (Dashboard → Get started)
17
+ --host <host> Your link domain, e.g. acme.strait.link
18
+ --scheme <scheme> Your app's custom scheme, e.g. acmeshop (guessed from the package name)
19
+ --framework <name> react-native | expo | flutter | android (detected by default)
20
+ --dry-run Print what would be written; write nothing
21
+ --force Replace an existing integration file
22
+ --yes, -y Never prompt; fail if something is missing
23
+ --help, -h Show this help
24
+
25
+ Environment
26
+ STRAIT_PUBLISHABLE_KEY, STRAIT_LINK_HOST are used when the flags are absent.
27
+
28
+ Examples
29
+ npx @straitlink/cli init
30
+ npx @straitlink/cli init --key st_pub_live_… --host acme.strait.link --dry-run
31
+ `;
32
+
33
+ /**
34
+ * @param {string[]} argv arguments after the program name
35
+ * @param {{ stdout, stderr, stdin, env, isTTY }} io
36
+ */
37
+ export async function main(argv, io = {}) {
38
+ const out = io.stdout ?? process.stdout;
39
+ const err = io.stderr ?? process.stderr;
40
+ const env = io.env ?? process.env;
41
+ const isTTY = io.isTTY ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
42
+ let args;
43
+ try {
44
+ args = parseArgs({
45
+ args: argv,
46
+ allowPositionals: true,
47
+ options: {
48
+ key: { type: 'string' },
49
+ host: { type: 'string' },
50
+ scheme: { type: 'string' },
51
+ framework: { type: 'string' },
52
+ 'dry-run': { type: 'boolean', default: false },
53
+ force: { type: 'boolean', default: false },
54
+ yes: { type: 'boolean', short: 'y', default: false },
55
+ help: { type: 'boolean', short: 'h', default: false },
56
+ },
57
+ });
58
+ } catch (e) {
59
+ err.write(`${e.message}\n\n${HELP}`);
60
+ return 2;
61
+ }
62
+ const [cmd, dirArg] = args.positionals;
63
+ if (args.values.help || !cmd) {
64
+ out.write(HELP);
65
+ return args.values.help ? 0 : 2;
66
+ }
67
+ if (cmd !== 'init') {
68
+ err.write(`Unknown command "${cmd}".\n\n${HELP}`);
69
+ return 2;
70
+ }
71
+
72
+ const v = args.values;
73
+ const dir = resolve(dirArg ?? '.');
74
+ let key = v.key ?? env.STRAIT_PUBLISHABLE_KEY;
75
+ let host = v.host ?? env.STRAIT_LINK_HOST;
76
+ let scheme = v.scheme;
77
+
78
+ try {
79
+ const canAsk = isTTY && !v.yes;
80
+ if (canAsk && (!key || !host || !scheme)) {
81
+ const ask = io.ask ?? (await terminalAsker(io.stdin ?? process.stdin, out));
82
+ try {
83
+ key = key ?? (await askValid(ask, 'Publishable key (Dashboard → Get started, st_pub_live_…): ', normalizeKey, err));
84
+ host = host ?? (await askValid(ask, 'Link host (e.g. acme.strait.link): ', normalizeHost, err));
85
+ if (!scheme) {
86
+ const guess = suggestScheme(detectProject(dir) ?? {});
87
+ scheme = await askValid(ask, `Custom scheme${guess ? ` [${guess}]` : ''}: `, (a) => normalizeScheme(a || guess || ''), err);
88
+ }
89
+ } finally {
90
+ ask.close?.();
91
+ }
92
+ }
93
+ const missing = [!key && '--key', !host && '--host'].filter(Boolean);
94
+ if (missing.length) throw new UsageError(`Missing ${missing.join(' and ')}. Pass them as flags, or run in a terminal to be asked.`);
95
+
96
+ const plan = planInit({ dir, key, host, scheme, framework: v.framework, force: v.force });
97
+ if (v['dry-run']) {
98
+ out.write(formatPlan(plan, { dryRun: true }) + '\n');
99
+ if (plan.conflicts.length) out.write(`\nNote: ${plan.conflicts.join(', ')} already exists; a real run needs --force.\n`);
100
+ return 0;
101
+ }
102
+ writeFiles(plan);
103
+ out.write(formatPlan(plan) + '\n');
104
+ return 0;
105
+ } catch (e) {
106
+ if (e instanceof UsageError) {
107
+ err.write(`strait: ${e.message}\n`);
108
+ return 2;
109
+ }
110
+ throw e;
111
+ }
112
+ }
113
+
114
+ async function askValid(ask, question, normalize, err) {
115
+ for (let i = 0; i < 3; i++) {
116
+ const a = (await ask.question(question)).trim();
117
+ try {
118
+ return normalize(a);
119
+ } catch (e) {
120
+ if (!(e instanceof UsageError)) throw e;
121
+ err.write(` ${e.message}\n`);
122
+ }
123
+ }
124
+ throw new UsageError('Giving up after 3 tries.');
125
+ }
126
+
127
+ async function terminalAsker(input, output) {
128
+ return createInterface({ input, output });
129
+ }
package/src/detect.mjs ADDED
@@ -0,0 +1,135 @@
1
+ // Works out what kind of app lives in a folder, by reading its files only (no
2
+ // commands run, nothing written).
3
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { PACKAGES } from './install.mjs';
6
+
7
+ export const FRAMEWORKS = ['react-native', 'expo', 'flutter', 'android'];
8
+ export const LABELS = { 'react-native': 'React Native', expo: 'Expo', flutter: 'Flutter', android: 'Android (Kotlin)' };
9
+
10
+ const read = (f) => {
11
+ try {
12
+ return readFileSync(f, 'utf8');
13
+ } catch {
14
+ return null;
15
+ }
16
+ };
17
+ const readJson = (f) => {
18
+ const t = read(f);
19
+ if (t == null) return null;
20
+ try {
21
+ return JSON.parse(t);
22
+ } catch {
23
+ return null;
24
+ }
25
+ };
26
+ const first = (dir, names) => names.map((n) => join(dir, n)).find((p) => existsSync(p)) ?? null;
27
+
28
+ /** applicationId (or namespace) from an app-module Gradle file. */
29
+ // The React Native SDK under its npm name, or its pre-rename name (GitHub installs of older tags).
30
+ const hasRnSdk = (deps) => Boolean(deps[PACKAGES.reactNative] || deps['@strait/sdk-react-native']);
31
+
32
+ export function gradlePackage(text) {
33
+ if (!text) return null;
34
+ const m = /\bapplicationId\s*(?:=\s*)?["']([A-Za-z][\w.]*)["']/.exec(text) ?? /\bnamespace\s*(?:=\s*)?["']([A-Za-z][\w.]*)["']/.exec(text);
35
+ return m ? m[1] : null;
36
+ }
37
+
38
+ /** The scheme(s) and hosts already declared in an AndroidManifest.xml. */
39
+ export function manifestLinks(text) {
40
+ if (!text) return { schemes: [], hosts: [], flutterDeepLinkingOff: false };
41
+ const schemes = [...text.matchAll(/android:scheme="([^"]+)"/g)].map((m) => m[1]);
42
+ const hosts = [...text.matchAll(/android:host="([^"]+)"/g)].map((m) => m[1]);
43
+ const flutterDeepLinkingOff = /android:name="flutter_deeplinking_enabled"\s+android:value="false"/.test(text);
44
+ return { schemes: [...new Set(schemes)], hosts: [...new Set(hosts)], flutterDeepLinkingOff };
45
+ }
46
+
47
+ /** The Java/Kotlin source root for an Android app module, if it exists. */
48
+ function androidSourceRoot(appDir) {
49
+ for (const s of ['src/main/java', 'src/main/kotlin']) {
50
+ const p = join(appDir, s);
51
+ if (existsSync(p) && statSync(p).isDirectory()) return p;
52
+ }
53
+ return null;
54
+ }
55
+
56
+ /**
57
+ * Returns null when the folder holds no app we recognise, otherwise:
58
+ * { framework, label, typescript, expoRouter, appName, androidPackage, iosBundleId,
59
+ * scheme, manifestPath, manifest: {schemes, hosts}, sdkInstalled, files: {...} }
60
+ */
61
+ export function detectProject(dir) {
62
+ const pkg = readJson(join(dir, 'package.json'));
63
+ const deps = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) };
64
+ const pubspec = read(join(dir, 'pubspec.yaml'));
65
+ const base = { appName: null, androidPackage: null, iosBundleId: null, scheme: null, typescript: false, expoRouter: false, sdkInstalled: false };
66
+
67
+ if (pkg && deps.expo) {
68
+ const app = readJson(join(dir, 'app.json'));
69
+ const expo = app?.expo ?? app ?? {};
70
+ const scheme = Array.isArray(expo.scheme) ? expo.scheme[0] : expo.scheme;
71
+ return finish(dir, {
72
+ ...base,
73
+ framework: 'expo',
74
+ typescript: existsSync(join(dir, 'tsconfig.json')),
75
+ expoRouter: Boolean(deps['expo-router']),
76
+ appName: expo.name ?? pkg.name ?? null,
77
+ androidPackage: expo.android?.package ?? null,
78
+ iosBundleId: expo.ios?.bundleIdentifier ?? null,
79
+ scheme: typeof scheme === 'string' ? scheme : null,
80
+ sdkInstalled: hasRnSdk(deps),
81
+ dynamicConfig: !app && Boolean(first(dir, ['app.config.js', 'app.config.ts'])),
82
+ androidDir: join(dir, 'android'),
83
+ });
84
+ }
85
+ if (pkg && deps['react-native']) {
86
+ return finish(dir, {
87
+ ...base,
88
+ framework: 'react-native',
89
+ typescript: existsSync(join(dir, 'tsconfig.json')),
90
+ appName: readJson(join(dir, 'app.json'))?.name ?? pkg.name ?? null,
91
+ androidPackage: gradlePackage(read(first(join(dir, 'android/app'), ['build.gradle', 'build.gradle.kts']) ?? '')),
92
+ sdkInstalled: hasRnSdk(deps),
93
+ androidDir: join(dir, 'android'),
94
+ });
95
+ }
96
+ if (pubspec && /\bsdk:\s*flutter\b/.test(pubspec)) {
97
+ return finish(dir, {
98
+ ...base,
99
+ framework: 'flutter',
100
+ appName: /^name:\s*(\S+)/m.exec(pubspec)?.[1] ?? null,
101
+ androidPackage: gradlePackage(read(first(join(dir, 'android/app'), ['build.gradle', 'build.gradle.kts']) ?? '')),
102
+ sdkInstalled: /^\s{2}strait_sdk:/m.test(pubspec),
103
+ androidDir: join(dir, 'android'),
104
+ });
105
+ }
106
+ const appGradle = first(join(dir, 'app'), ['build.gradle.kts', 'build.gradle']);
107
+ const appGradleText = appGradle ? read(appGradle) : null;
108
+ if (appGradleText && /com\.android\.application|android\.application/.test(appGradleText)) {
109
+ return finish(dir, {
110
+ ...base,
111
+ framework: 'android',
112
+ appName: /rootProject\.name\s*=\s*["']([^"']+)["']/.exec(read(first(dir, ['settings.gradle.kts', 'settings.gradle']) ?? '') ?? '')?.[1] ?? null,
113
+ androidPackage: gradlePackage(appGradleText),
114
+ sdkInstalled: /strait-sdk-android/.test(appGradleText),
115
+ androidDir: dir,
116
+ sourceRoot: androidSourceRoot(join(dir, 'app')),
117
+ });
118
+ }
119
+ return null;
120
+ }
121
+
122
+ function finish(dir, p) {
123
+ const manifestPath = p.androidDir ? first(p.androidDir, ['app/src/main/AndroidManifest.xml']) : null;
124
+ const { androidDir, ...rest } = p;
125
+ return { ...rest, label: LABELS[p.framework], dir, manifestPath, manifest: manifestLinks(manifestPath ? read(manifestPath) : null) };
126
+ }
127
+
128
+ /** Folders are listed in error messages so people can see why nothing matched. */
129
+ export function describeFolder(dir) {
130
+ try {
131
+ return readdirSync(dir).filter((n) => !n.startsWith('.')).slice(0, 12).join(', ') || '(empty)';
132
+ } catch {
133
+ return '(missing)';
134
+ }
135
+ }
package/src/init.mjs ADDED
@@ -0,0 +1,87 @@
1
+ // `strait init`: detect the app, collect key + host + scheme, write the
2
+ // integration file(s), print the platform steps. Pure planning is separated from
3
+ // writing so --dry-run and the tests see exactly what would happen.
4
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
5
+ import { dirname, join, resolve } from 'node:path';
6
+ import { FRAMEWORKS, LABELS, describeFolder, detectProject } from './detect.mjs';
7
+ import { INSTALL } from './install.mjs';
8
+ import { integrationFiles, nextSteps } from './templates.mjs';
9
+ import { UsageError, normalizeHost, normalizeKey, normalizeScheme, suggestScheme } from './validate.mjs';
10
+
11
+ /**
12
+ * Works out everything without touching the disk.
13
+ * @param {object} o { dir, key, host, scheme, framework, force }
14
+ * @returns {{ project, opts, files: Array<{path, abs, contents, why, exists}>, steps, conflicts: string[] }}
15
+ */
16
+ export function planInit({ dir, key, host, scheme, framework, force = false, installSource = INSTALL }) {
17
+ const root = resolve(dir ?? '.');
18
+ let project = detectProject(root);
19
+ if (framework) {
20
+ if (!FRAMEWORKS.includes(framework)) throw new UsageError(`--framework must be one of ${FRAMEWORKS.join(', ')}`);
21
+ project = project && project.framework === framework ? project : { ...(project ?? emptyProject(root)), framework, label: LABELS[framework] };
22
+ }
23
+ if (!project) {
24
+ throw new UsageError(
25
+ `No React Native, Expo, Flutter or Android app found in ${root} (it has: ${describeFolder(root)}). ` +
26
+ 'Run this in your app folder, or pass --framework react-native|expo|flutter|android.',
27
+ );
28
+ }
29
+ const k = normalizeKey(key);
30
+ const h = normalizeHost(host);
31
+ const chosen = scheme ?? suggestScheme(project);
32
+ if (!chosen) throw new UsageError("Couldn't guess your app's custom scheme. Pass --scheme, for example --scheme acmeshop.");
33
+ const opts = { key: k, host: h, scheme: normalizeScheme(chosen) };
34
+ const files = integrationFiles(project, opts).map((f) => {
35
+ const abs = join(root, f.path);
36
+ return { ...f, abs, exists: existsSync(abs) };
37
+ });
38
+ const conflicts = force ? [] : files.filter((f) => f.exists).map((f) => f.path);
39
+ return { project, opts, files, steps: nextSteps(project, opts, installSource), conflicts };
40
+ }
41
+
42
+ function emptyProject(dir) {
43
+ return { dir, appName: null, androidPackage: null, iosBundleId: null, scheme: null, typescript: false, expoRouter: false, sdkInstalled: false, manifestPath: null, manifest: { schemes: [], hosts: [] } };
44
+ }
45
+
46
+ /** Writes the planned files. Returns the paths written. */
47
+ export function writeFiles(plan) {
48
+ if (plan.conflicts.length) throw new UsageError(`Not overwriting ${plan.conflicts.join(', ')}. Use --force to replace, or delete it first.`);
49
+ for (const f of plan.files) {
50
+ mkdirSync(dirname(f.abs), { recursive: true });
51
+ writeFileSync(f.abs, f.contents);
52
+ }
53
+ return plan.files.map((f) => f.path);
54
+ }
55
+
56
+ // ---------- output ----------
57
+
58
+ const indent = (s, n = 4) => s.split('\n').map((l) => (l ? ' '.repeat(n) + l : l)).join('\n');
59
+
60
+ export function formatPlan(plan, { dryRun = false } = {}) {
61
+ const { project, opts, files, steps } = plan;
62
+ const out = [];
63
+ const facts = [project.label, project.androidPackage && `package ${project.androidPackage}`, project.expoRouter && 'Expo Router', project.typescript && 'TypeScript'].filter(Boolean);
64
+ out.push(`Found: ${facts.join(' · ')}`);
65
+ out.push(`Link host: ${opts.host} · scheme: ${opts.scheme}://`);
66
+ out.push('');
67
+ out.push(dryRun ? 'Dry run: nothing written. These files would be created:' : 'Created:');
68
+ for (const f of files) out.push(` ${f.path}${f.exists ? ' (replaced)' : ''} ${f.why}`);
69
+ if (dryRun) {
70
+ for (const f of files) {
71
+ out.push('');
72
+ out.push(`--- ${f.path}`);
73
+ out.push(f.contents.trimEnd());
74
+ }
75
+ }
76
+ out.push('');
77
+ out.push('Next steps:');
78
+ steps.forEach((s, i) => {
79
+ out.push('');
80
+ out.push(`${i + 1}. ${s.title}${s.done ? ' [already done]' : ''}`);
81
+ if (s.body) out.push(indent(s.body, 3));
82
+ if (s.code) out.push(indent(s.code));
83
+ });
84
+ out.push('');
85
+ out.push('Docs: https://straitlink.in/docs/ Checker: https://straitlink.in/tools/app-links-checker/');
86
+ return out.join('\n');
87
+ }
@@ -0,0 +1,48 @@
1
+ // THE one place the CLI's SDK install lines come from. Mirrors site/site.config.json
2
+ // `install` and dashboard/src/lib/install.ts (test/install.test.mjs fails if they
3
+ // drift when the site repo sits next to this one).
4
+ //
5
+ // - SDK repos moved to an organisation: change `githubOwner`.
6
+ // - SDKs published to npm / pub.dev / Maven Central: change `source` to "registry", or per
7
+ // registry { npm, pub, maven } (npm = React Native, pub = Flutter, maven = Android).
8
+ // 16 Oct 2026: npm and pub.dev are published; Android stays on JitPack.
9
+ // Never spell out the owner anywhere else in this package.
10
+
11
+ export const INSTALL = {
12
+ source: { npm: 'registry', pub: 'registry', maven: 'github' },
13
+ githubOwner: 'bazingga08',
14
+ };
15
+
16
+ /** The source for one registry: `source` as one word applies to all three. */
17
+ export const sourceFor = (src, registry) => (typeof src.source === 'string' ? src.source : src.source[registry]);
18
+
19
+ /** SDK versions the generated code is written for (site.config.json packages). */
20
+ export const VERSIONS = {
21
+ version: '0.9.2', // Swift (Unity 0.8.5 is not written by this CLI)
22
+ rnVersion: '0.9.1',
23
+ flutterVersion: '0.9.2',
24
+ androidVersion: '0.8.0',
25
+ };
26
+
27
+ export const PACKAGES = {
28
+ reactNative: '@straitlink/react-native',
29
+ flutter: 'strait_sdk',
30
+ android: 'strait-sdk-android',
31
+ };
32
+
33
+ export function installSpecs(src = INSTALL, v = VERSIONS) {
34
+ const o = src.githubOwner;
35
+ const gh = (repo) => `https://github.com/${o}/${repo}`;
36
+ const regNpm = sourceFor(src, 'npm') === 'registry';
37
+ const regPub = sourceFor(src, 'pub') === 'registry';
38
+ const reg = sourceFor(src, 'maven') === 'registry';
39
+ return {
40
+ reactNative: regNpm ? `${PACKAGES.reactNative}@${v.rnVersion}` : `github:${o}/strait-sdk-react-native#v${v.rnVersion}`,
41
+ flutterPubspec: regPub
42
+ ? ` ${PACKAGES.flutter}: ^${v.flutterVersion}`
43
+ : ` ${PACKAGES.flutter}:\n git:\n url: ${gh('strait-sdk-flutter')}\n ref: v${v.flutterVersion}`,
44
+ android: reg ? `in.straitlink:${PACKAGES.android}:${v.androidVersion}` : `com.github.${o}:${PACKAGES.android}:v${v.androidVersion}`,
45
+ /** Extra Gradle repository the Android coordinate needs (null once on Maven Central). */
46
+ androidRepository: reg ? null : 'https://jitpack.io',
47
+ };
48
+ }
@@ -0,0 +1,385 @@
1
+ // What `strait init` writes and prints, per framework. Every snippet mirrors the
2
+ // docs (site/content/docs/sdks/*) so the two never disagree. Install lines come
3
+ // from src/install.mjs only.
4
+ import { join } from 'node:path';
5
+ import { PACKAGES, VERSIONS, installSpecs } from './install.mjs';
6
+
7
+ const HEADER = (pkg, version) => `Created by \`strait init\` (${pkg} ${version}). Safe to edit.`;
8
+
9
+ // ---------- integration files ----------
10
+
11
+ function rnLinks({ key, host }, { withRouting }) {
12
+ return `// ${HEADER(PACKAGES.reactNative, VERSIONS.rnVersion)}
13
+ // Import this file once from your entry file (index.js) so the launch link is caught.
14
+ import AsyncStorage from '@react-native-async-storage/async-storage';
15
+ import { PlayInstallReferrer } from 'react-native-play-install-referrer';
16
+ import { createStrait, fromPlayInstallReferrer } from '${PACKAGES.reactNative}';
17
+
18
+ export const links = createStrait({
19
+ publishableKey: '${key}', // safe in apps; never the secret key
20
+ endpoint: 'https://${host}', // your workspace's link domain
21
+ storage: AsyncStorage, // required for reliable analytics
22
+ installReferrer: fromPlayInstallReferrer(PlayInstallReferrer),
23
+ });
24
+ ${
25
+ withRouting
26
+ ? `
27
+ // Every link arrives here: app opened from a link, browser hand-off, and the
28
+ // first open after an install (kind: 'deferred'). Past events replay to late
29
+ // subscribers, so your navigator can subscribe after start().
30
+ links.onLink((e) => {
31
+ console.log('Strait link:', e.kind, e.route, e.matched, e.path);
32
+ if (e.matched && e.path) {
33
+ // Open the screen for e.path with e.params, for example:
34
+ // navigationRef.navigate(screenFor(e.path), e.params);
35
+ }
36
+ });
37
+ `
38
+ : `
39
+ // Routing lives in app/_layout.tsx (links.onLink). This log helps while you set up.
40
+ links.onLink((e) => console.log('Strait link:', e.kind, e.route, e.matched, e.path));
41
+ `
42
+ }
43
+ links.start();
44
+ `;
45
+ }
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
49
+ // real destination. Keep Expo Router from routing the raw link URL.
50
+ // If your app has other deep links of its own (a sign-in callback), return \`path\` for those.
51
+ export function redirectSystemPath({ path }: { path: string; initial: boolean }) {
52
+ return '/';
53
+ }
54
+ `;
55
+
56
+ function flutterLinks({ key, host }) {
57
+ const f = PACKAGES.flutter;
58
+ return `// ${HEADER(f, VERSIONS.flutterVersion)}
59
+ // Call \`await startLinks();\` in main() before runApp.
60
+ import 'dart:async';
61
+ import 'dart:io' show Platform;
62
+ import 'dart:ui';
63
+
64
+ import 'package:app_links/app_links.dart';
65
+ import 'package:${f}/${f}.dart';
66
+ import 'package:flutter/widgets.dart';
67
+ import 'package:flutter_timezone/flutter_timezone.dart';
68
+ import 'package:play_install_referrer/play_install_referrer.dart';
69
+ import 'package:shared_preferences/shared_preferences.dart';
70
+
71
+ /// Persists the once-per-install flag and unsent open reports.
72
+ class PrefsStore implements KeyValueStore {
73
+ PrefsStore(this.prefs);
74
+ final SharedPreferences prefs;
75
+ @override
76
+ Future<String?> get(String key) async => prefs.getString(key);
77
+ @override
78
+ Future<void> set(String key, String value) => prefs.setString(key, value);
79
+ }
80
+
81
+ /// Feeds Flutter lifecycle changes to the SDK.
82
+ class LifecycleFeed with WidgetsBindingObserver {
83
+ final _ctl = StreamController<AppLifecycle>.broadcast(sync: true);
84
+ Stream<AppLifecycle> get stream => _ctl.stream;
85
+ LifecycleFeed() { WidgetsBinding.instance.addObserver(this); }
86
+ @override
87
+ void didChangeAppLifecycleState(AppLifecycleState s) => _ctl.add(switch (s) {
88
+ AppLifecycleState.resumed => AppLifecycle.active,
89
+ AppLifecycleState.inactive => AppLifecycle.inactive,
90
+ _ => AppLifecycle.background,
91
+ });
92
+ }
93
+
94
+ late final StraitLinks links;
95
+
96
+ Future<void> startLinks() async {
97
+ WidgetsFlutterBinding.ensureInitialized();
98
+ final timezone = await FlutterTimezone.getLocalTimezone(); // IANA, e.g. Asia/Kolkata
99
+ links = StraitLinks(
100
+ publishableKey: '${key}', // safe in apps; never the secret key
101
+ endpoint: 'https://${host}', // your workspace's link domain
102
+ platform: Platform.isIOS ? 'ios' : Platform.isAndroid ? 'android' : 'other',
103
+ storage: PrefsStore(await SharedPreferences.getInstance()),
104
+ installReferrer: () async =>
105
+ Platform.isAndroid ? (await PlayInstallReferrer.installReferrer).installReferrer : null,
106
+ deviceFields: () {
107
+ final view = PlatformDispatcher.instance.views.first;
108
+ return DeviceFields(
109
+ screenWidth: portraitScreenWidth(view.physicalSize.width / view.devicePixelRatio, view.physicalSize.height / view.devicePixelRatio),
110
+ pixelRatio: view.devicePixelRatio,
111
+ language: PlatformDispatcher.instance.locale.toLanguageTag(),
112
+ timezone: timezone,
113
+ );
114
+ },
115
+ );
116
+
117
+ // Every link arrives here: app opened from a link, browser hand-off, and the
118
+ // first open after an install.
119
+ links.onLink.listen((e) {
120
+ debugPrint('Strait link: \${e.kind.name} \${e.route.value} \${e.matched} \${e.path}');
121
+ if (e.matched && e.path != null) {
122
+ // Open the screen for e.path with e.params, for example:
123
+ // navigatorKey.currentState?.pushNamed(e.path!, arguments: e.params);
124
+ }
125
+ });
126
+
127
+ final appLinks = AppLinks();
128
+ await links.start(
129
+ initialUrl: (await appLinks.getInitialLink())?.toString(),
130
+ urls: appLinks.uriLinkStream.map((u) => u.toString()),
131
+ lifecycle: LifecycleFeed().stream,
132
+ );
133
+ }
134
+ `;
135
+ }
136
+
137
+ function androidLinks({ key, host }, pkg) {
138
+ return `// ${HEADER(PACKAGES.android, VERSIONS.androidVersion)}
139
+ package ${pkg}
140
+
141
+ import android.app.Application
142
+ import android.content.Context
143
+ import androidx.lifecycle.Lifecycle
144
+ import androidx.lifecycle.LifecycleEventObserver
145
+ import androidx.lifecycle.ProcessLifecycleOwner
146
+ import com.android.installreferrer.api.InstallReferrerClient
147
+ import com.android.installreferrer.api.InstallReferrerStateListener
148
+ import dev.strait.sdk.AppLifecycleState
149
+ import dev.strait.sdk.DeviceFields
150
+ import dev.strait.sdk.KeyValueStore
151
+ import dev.strait.sdk.StraitClient
152
+ import dev.strait.sdk.StraitConfig
153
+ import dev.strait.sdk.portraitScreenWidth
154
+
155
+ /** Call StraitLinks.init(this) from your Application's onCreate(). */
156
+ object StraitLinks {
157
+ lateinit var client: StraitClient
158
+ private set
159
+
160
+ fun init(app: Application) {
161
+ val prefs = app.getSharedPreferences("links", Context.MODE_PRIVATE)
162
+ client = StraitClient(StraitConfig(
163
+ publishableKey = "${key}", // safe in apps; never the secret key
164
+ endpoint = "https://${host}", // your workspace's link domain
165
+ storage = object : KeyValueStore {
166
+ override fun get(key: String) = prefs.getString(key, null)
167
+ override fun set(key: String, value: String) { prefs.edit().putString(key, value).apply() }
168
+ },
169
+ installReferrer = { readInstallReferrer(app) },
170
+ device = {
171
+ val m = app.resources.displayMetrics
172
+ DeviceFields(
173
+ screenWidth = portraitScreenWidth(m.widthPixels / m.density.toDouble(), m.heightPixels / m.density.toDouble()),
174
+ pixelRatio = m.density.toDouble(),
175
+ language = java.util.Locale.getDefault().toLanguageTag(),
176
+ timezone = java.util.TimeZone.getDefault().id,
177
+ )
178
+ },
179
+ // Network calls block the calling thread; listeners run here too.
180
+ executor = java.util.concurrent.Executors.newSingleThreadExecutor(),
181
+ ))
182
+
183
+ ProcessLifecycleOwner.get().lifecycle.addObserver(LifecycleEventObserver { _, e ->
184
+ when (e) {
185
+ Lifecycle.Event.ON_RESUME -> client.onAppState(AppLifecycleState.ACTIVE)
186
+ Lifecycle.Event.ON_PAUSE -> client.onAppState(AppLifecycleState.INACTIVE)
187
+ Lifecycle.Event.ON_STOP -> client.onAppState(AppLifecycleState.BACKGROUND)
188
+ else -> Unit
189
+ }
190
+ })
191
+ }
192
+ }
193
+
194
+ /** The Play Install Referrer: how a link tapped before the install reaches the app. */
195
+ fun readInstallReferrer(context: Context): String? {
196
+ val latch = java.util.concurrent.CountDownLatch(1)
197
+ var result: String? = null
198
+ val client = InstallReferrerClient.newBuilder(context).build()
199
+ try {
200
+ client.startConnection(object : InstallReferrerStateListener {
201
+ override fun onInstallReferrerSetupFinished(code: Int) {
202
+ if (code == InstallReferrerClient.InstallReferrerResponse.OK) {
203
+ result = runCatching { client.installReferrer.installReferrer }.getOrNull()
204
+ }
205
+ latch.countDown()
206
+ }
207
+ override fun onInstallReferrerServiceDisconnected() { latch.countDown() }
208
+ })
209
+ latch.await(5, java.util.concurrent.TimeUnit.SECONDS)
210
+ } catch (_: Exception) {
211
+ } finally {
212
+ runCatching { client.endConnection() }
213
+ }
214
+ return result
215
+ }
216
+ `;
217
+ }
218
+
219
+ /** The files `strait init` writes, as [{ path (relative), contents, why }]. */
220
+ export function integrationFiles(project, opts) {
221
+ const ext = project.typescript ? 'ts' : 'js';
222
+ switch (project.framework) {
223
+ case 'react-native':
224
+ return [{ path: `links.${ext}`, contents: rnLinks(opts, { withRouting: true }), why: 'creates the client and handles every link' }];
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' });
228
+ return files;
229
+ }
230
+ case 'flutter':
231
+ return [{ path: 'lib/links.dart', contents: flutterLinks(opts), why: 'creates the client and handles every link' }];
232
+ case 'android': {
233
+ const pkg = project.androidPackage ?? 'com.example.app';
234
+ const root = project.sourceRoot ? project.sourceRoot.slice(project.dir.length + 1) : 'app/src/main/java';
235
+ return [{ path: join(root, ...pkg.split('.'), 'StraitLinks.kt'), contents: androidLinks(opts, pkg), why: 'creates the client and reads the Play Install Referrer' }];
236
+ }
237
+ default:
238
+ throw new Error(`unknown framework ${project.framework}`);
239
+ }
240
+ }
241
+
242
+ // ---------- printed steps ----------
243
+
244
+ export function manifestXml({ host, scheme }) {
245
+ return `<activity android:name=".MainActivity" android:launchMode="singleTask" android:exported="true">
246
+ <!-- Verified links: https://${host}/… opens the app directly -->
247
+ <intent-filter android:autoVerify="true">
248
+ <action android:name="android.intent.action.VIEW" />
249
+ <category android:name="android.intent.category.DEFAULT" />
250
+ <category android:name="android.intent.category.BROWSABLE" />
251
+ <data android:scheme="https" android:host="${host}" />
252
+ </intent-filter>
253
+ <!-- Browser hand-off: ${scheme}://shop.example.com/product/42 -->
254
+ <intent-filter>
255
+ <action android:name="android.intent.action.VIEW" />
256
+ <category android:name="android.intent.category.DEFAULT" />
257
+ <category android:name="android.intent.category.BROWSABLE" />
258
+ <data android:scheme="${scheme}" />
259
+ </intent-filter>
260
+ </activity>`;
261
+ }
262
+
263
+ function expoAppJson({ host, scheme }, project) {
264
+ return JSON.stringify(
265
+ {
266
+ expo: {
267
+ scheme,
268
+ android: {
269
+ package: project.androidPackage ?? 'com.yourcompany.yourapp',
270
+ intentFilters: [{ action: 'VIEW', autoVerify: true, data: [{ scheme: 'https', host }], category: ['BROWSABLE', 'DEFAULT'] }],
271
+ },
272
+ ios: { bundleIdentifier: project.iosBundleId ?? 'com.yourcompany.yourapp', associatedDomains: [`applinks:${host}`] },
273
+ },
274
+ },
275
+ null,
276
+ 2,
277
+ );
278
+ }
279
+
280
+ const EXPO_LAYOUT = `import { Stack, router, useRootNavigationState, type Href } from 'expo-router';
281
+ import { useEffect } from 'react';
282
+ import { links } from '../links';
283
+
284
+ export default function RootLayout() {
285
+ const ready = useRootNavigationState()?.key;
286
+ useEffect(() => {
287
+ if (!ready) return;
288
+ // Opened from a link, or first open after install: same callback.
289
+ return links.onLink((e) => {
290
+ if (e.matched && e.path) router.push({ pathname: e.path, params: e.params } as Href);
291
+ });
292
+ }, [ready]);
293
+ return <Stack />;
294
+ }`;
295
+
296
+ const keytool = (fw) =>
297
+ fw === 'expo'
298
+ ? 'keytool -list -v -keystore android/app/debug.keystore -storepass android | grep SHA256 # local builds; EAS builds: eas credentials'
299
+ : fw === 'flutter' || fw === 'android'
300
+ ? 'keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android | grep SHA256'
301
+ : 'keytool -list -v -keystore android/app/debug.keystore -storepass android | grep SHA256';
302
+
303
+ /**
304
+ * The steps printed after the files are written: [{ title, body, code?, lang? }].
305
+ * `done` marks a step the project already has.
306
+ */
307
+ export function nextSteps(project, opts, installSrc) {
308
+ const { host, scheme } = opts;
309
+ const inst = installSpecs(installSrc);
310
+ const pkg = project.androidPackage ?? 'com.yourcompany.yourapp';
311
+ const hasHost = project.manifest.hosts.includes(host);
312
+ const hasScheme = project.manifest.schemes.includes(scheme);
313
+ const steps = [];
314
+ const fw = project.framework;
315
+
316
+ // 1. Install
317
+ if (fw === 'react-native') {
318
+ steps.push({ title: 'Install the SDK', done: project.sdkInstalled, code: `npm install ${inst.reactNative} @react-native-async-storage/async-storage react-native-play-install-referrer`, lang: 'bash' });
319
+ } else if (fw === 'expo') {
320
+ steps.push({ title: 'Install the SDK', done: project.sdkInstalled, code: `npm install ${inst.reactNative}\nnpx expo install @react-native-async-storage/async-storage react-native-play-install-referrer`, lang: 'bash' });
321
+ } else if (fw === 'flutter') {
322
+ steps.push({ title: 'Add the SDK to pubspec.yaml, then the helper packages', done: project.sdkInstalled, code: `dependencies:\n${inst.flutterPubspec}\n\n# then:\nflutter pub add app_links shared_preferences play_install_referrer flutter_timezone`, lang: 'yaml' });
323
+ } else {
324
+ const repo = inst.androidRepository ? `// settings.gradle.kts → dependencyResolutionManagement.repositories\nmaven("${inst.androidRepository}")\n\n` : '';
325
+ steps.push({ title: 'Add the dependencies', done: project.sdkInstalled, code: `${repo}// app/build.gradle.kts → dependencies\nimplementation("${inst.android}")\nimplementation("com.android.installreferrer:installreferrer:2.2")\nimplementation("androidx.lifecycle:lifecycle-process:2.8.7")`, lang: 'kotlin' });
326
+ }
327
+
328
+ // 2. Start it
329
+ 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.` });
330
+ if (fw === 'expo') {
331
+ 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.' : undefined });
332
+ if (project.expoRouter) steps.push({ title: 'Route links in app/_layout.tsx', code: EXPO_LAYOUT, lang: 'tsx' });
333
+ else steps.push({ title: 'Load links at startup', body: "Add `import './links';` to your entry file." });
334
+ 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' });
335
+ }
336
+ if (fw === 'flutter') steps.push({ title: 'Start links in main()', code: "import 'links.dart';\n\nFuture<void> main() async {\n await startLinks();\n runApp(const MyApp());\n}", lang: 'dart' });
337
+ if (fw === 'android') {
338
+ steps.push({ title: 'Initialise it in your Application', code: 'class App : Application() {\n override fun onCreate() {\n super.onCreate()\n StraitLinks.init(this)\n }\n}\n// AndroidManifest.xml: <application android:name=".App" …>', lang: 'kotlin' });
339
+ steps.push({ title: 'Pass links from MainActivity', code: 'override fun onCreate(savedInstanceState: Bundle?) {\n super.onCreate(savedInstanceState)\n val links = StraitLinks.client\n links.onLink { e -> runOnUiThread { if (e.matched) navigate(e.path, e.params) } }\n if (savedInstanceState == null) links.start(intent?.dataString)\n}\n\noverride fun onNewIntent(intent: Intent) {\n super.onNewIntent(intent)\n intent.dataString?.let(StraitLinks.client::handleUrl) // app was already running\n}', lang: 'kotlin' });
340
+ }
341
+
342
+ // 3. Manifest
343
+ if (fw !== 'expo') {
344
+ const where = fw === 'android' ? 'app/src/main/AndroidManifest.xml' : 'android/app/src/main/AndroidManifest.xml';
345
+ const flutterMeta = '<!-- Flutter only, inside <activity>: let the SDK handle links -->\n<meta-data android:name="flutter_deeplinking_enabled" android:value="false" />';
346
+ const needsFlutterMeta = fw === 'flutter' && !project.manifest.flutterDeepLinkingOff;
347
+ const filtersDone = hasHost && hasScheme;
348
+ const done = filtersDone && !needsFlutterMeta;
349
+ steps.push({
350
+ title: `Add the intent filters to ${where}`,
351
+ done,
352
+ body: done
353
+ ? `Already there: ${host} and ${scheme}://.`
354
+ : filtersDone
355
+ ? `The intent filters for ${host} and ${scheme}:// are already there. Add this line inside your main activity:`
356
+ : 'Inside your main activity. Keep the intent filters you already have.',
357
+ code: done ? undefined : [filtersDone ? null : manifestXml(opts), needsFlutterMeta ? flutterMeta : null].filter(Boolean).join('\n'),
358
+ lang: 'xml',
359
+ });
360
+ steps.push({ title: 'iPhone (beta)', body: `In Xcode add Associated Domains → applinks:${host}, and ${scheme} under Info → URL Types.${fw === 'flutter' ? ' Set FlutterDeepLinkingEnabled = NO in Info.plist.' : ''}` });
361
+ }
362
+
363
+ // 4. Dashboard + verification files
364
+ steps.push({
365
+ title: 'Fill in Dashboard → Settings',
366
+ body: `Package name ${pkg}, custom scheme ${scheme}, and the SHA-256 fingerprint of every certificate your app is signed with (debug, upload, and Play App Signing from Play Console → App integrity). For iPhone (beta), your Apple Team ID and bundle ID.`,
367
+ code: keytool(fw),
368
+ lang: 'bash',
369
+ });
370
+ steps.push({
371
+ title: 'Check the verification files your link domain serves',
372
+ body: 'Each returns 404 until the matching Settings fields are filled in. assetlinks.json lets Android open https links in the app directly; apple-app-site-association does the same on iPhone (beta).',
373
+ code: `curl https://${host}/.well-known/assetlinks.json\ncurl https://${host}/.well-known/apple-app-site-association`,
374
+ lang: 'bash',
375
+ });
376
+
377
+ // 5. Try it
378
+ steps.push({
379
+ title: 'Try it',
380
+ body: 'Rebuild and install the app, then run this. Your log shows a "Strait link:" line. After that, tap a real link from Messages or Gmail.',
381
+ code: `adb shell am start -W -a android.intent.action.VIEW -d "${scheme}://shop.example.com/product/42"\nadb shell pm get-app-links ${pkg} # look for ${host}: verified`,
382
+ lang: 'bash',
383
+ });
384
+ return steps;
385
+ }
package/src/ttfl.mjs ADDED
@@ -0,0 +1,73 @@
1
+ // Time to first link: how long the scripted parts of the path take.
2
+ // measureInit – `strait init` on fresh copies of the fixture apps (offline).
3
+ // measureLive – create a link through the API, then tap it as Android Chrome
4
+ // would (no redirect follow) and read the verification file.
5
+ // The human parts (sign-up, rebuild, tapping on a phone) are timed by hand with the
6
+ // protocol in docs/TIME-TO-FIRST-LINK.md.
7
+ import { cpSync, mkdtempSync, readdirSync, rmSync } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
+ import { join } from 'node:path';
10
+ import { planInit, writeFiles } from './init.mjs';
11
+
12
+ const ms = (t0) => Math.round((performance.now() - t0) * 10) / 10;
13
+
14
+ export function measureInit(fixturesDir, { key, host }) {
15
+ const rows = [];
16
+ for (const name of readdirSync(fixturesDir)) {
17
+ const tmp = mkdtempSync(join(tmpdir(), `strait-ttfl-${name}-`));
18
+ cpSync(join(fixturesDir, name), tmp, { recursive: true });
19
+ const t0 = performance.now();
20
+ try {
21
+ const plan = planInit({ dir: tmp, key, host });
22
+ const written = writeFiles(plan);
23
+ rows.push({ fixture: name, framework: plan.project.framework, ms: ms(t0), files: written, steps: plan.steps.length });
24
+ } catch (e) {
25
+ rows.push({ fixture: name, framework: null, ms: ms(t0), error: e.message });
26
+ } finally {
27
+ rmSync(tmp, { recursive: true, force: true });
28
+ }
29
+ }
30
+ return rows;
31
+ }
32
+
33
+ export const ANDROID_CHROME_UA =
34
+ 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36';
35
+
36
+ /**
37
+ * @param {{ api: string, secretKey: string, slug?: string, longUrl?: string, fetch?: typeof fetch }} o
38
+ * @returns {Promise<Array<{ step, ms, ok, detail }>>}
39
+ */
40
+ export async function measureLive({ api, secretKey, slug, longUrl = 'https://shop.example.com/product/42', fetch: f = fetch }) {
41
+ if (!/^st_(live|test)_/.test(secretKey ?? '')) throw new Error('STRAIT_SECRET_KEY must be a secret key (st_live_…)');
42
+ const base = `https://${api.replace(/^https?:\/\//, '').replace(/\/+$/, '')}`;
43
+ const steps = [];
44
+ const s = slug ?? `ttfl-${Date.now().toString(36)}`;
45
+
46
+ let t0 = performance.now();
47
+ const created = await f(`${base}/v1/links`, {
48
+ method: 'POST',
49
+ headers: { authorization: `Bearer ${secretKey}`, 'content-type': 'application/json' },
50
+ body: JSON.stringify({ slug: s, longUrl }),
51
+ });
52
+ const body = await created.json().catch(() => ({}));
53
+ steps.push({ step: 'create link (POST /v1/links)', ms: ms(t0), ok: created.status === 201, detail: `${created.status} ${body.shortUrl ?? body.error ?? ''}`.trim() });
54
+ if (created.status !== 201 || !body.shortUrl) return steps;
55
+
56
+ t0 = performance.now();
57
+ const tap = await f(body.shortUrl, { redirect: 'manual', headers: { 'user-agent': ANDROID_CHROME_UA } });
58
+ const loc = tap.headers.get('location') ?? '';
59
+ const where = loc ? loc.split(':')[0] + '://…' : `${tap.status} page`;
60
+ steps.push({ step: 'tap as Android Chrome', ms: ms(t0), ok: (tap.status >= 300 && tap.status < 400 && Boolean(loc)) || tap.status === 200, detail: `${tap.status} → ${where}` });
61
+
62
+ t0 = performance.now();
63
+ const host = new URL(body.shortUrl).host;
64
+ const al = await f(`https://${host}/.well-known/assetlinks.json`);
65
+ steps.push({ step: 'assetlinks.json', ms: ms(t0), ok: al.status === 200, detail: al.status === 200 ? '200 (App Links can verify)' : `${al.status} (fill package + SHA-256 in Settings)` });
66
+ return steps;
67
+ }
68
+
69
+ export function formatRows(rows, cols) {
70
+ const widths = cols.map((c) => Math.max(c.length, ...rows.map((r) => String(r[c] ?? '').length)));
71
+ const line = (r) => cols.map((c, i) => String(r[c] ?? '').padEnd(widths[i])).join(' ');
72
+ return [line(Object.fromEntries(cols.map((c) => [c, c]))), widths.map((w) => '-'.repeat(w)).join(' '), ...rows.map(line)].join('\n');
73
+ }
@@ -0,0 +1,55 @@
1
+ // Input checks for `strait init`. Each throws a UsageError with a message a person
2
+ // can act on; the CLI prints it and exits 2.
3
+
4
+ export class UsageError extends Error {}
5
+
6
+ const PUB_KEY = /^st_pub_(live|test)_[A-Za-z0-9]{8,}$/;
7
+ const SECRET_KEY = /^st_(live|test)_/;
8
+ const LABEL = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
9
+
10
+ /** A publishable key (st_pub_live_…). A secret key is refused: it must never ship in an app. */
11
+ export function normalizeKey(value) {
12
+ const k = String(value ?? '').trim();
13
+ if (!k) throw new UsageError('A publishable key is required (--key st_pub_live_…). Find it in Dashboard → Get started.');
14
+ if (SECRET_KEY.test(k)) {
15
+ throw new UsageError(
16
+ 'That is a secret key (st_live_…). Apps must use the publishable key (st_pub_live_…) from Dashboard → Get started. ' +
17
+ 'If the secret key was pasted somewhere public, rotate it in Dashboard → Settings → Secret keys.',
18
+ );
19
+ }
20
+ if (!PUB_KEY.test(k)) throw new UsageError(`"${k}" is not a publishable key. It starts with st_pub_live_ (Dashboard → Get started).`);
21
+ return k;
22
+ }
23
+
24
+ /** "https://Acme.strait.link/summer" → "acme.strait.link". */
25
+ export function normalizeHost(value) {
26
+ let h = String(value ?? '').trim().toLowerCase();
27
+ if (!h) throw new UsageError('A link host is required (--host acme.strait.link). It is your workspace link domain in Dashboard → Get started.');
28
+ h = h.replace(/^[a-z][a-z0-9+.-]*:\/\//, '').replace(/[/?#].*$/, '').replace(/\.$/, '');
29
+ const labels = h.split('.');
30
+ if (h.length > 253 || labels.length < 2 || !labels.every((l) => LABEL.test(l)) || !/^[a-z]{2,}$/.test(labels.at(-1))) {
31
+ throw new UsageError(`"${value}" is not a link host. Use your link domain, for example acme.strait.link.`);
32
+ }
33
+ return h;
34
+ }
35
+
36
+ /** A custom URL scheme such as "acmeshop". */
37
+ export function normalizeScheme(value) {
38
+ const s = String(value ?? '').trim().toLowerCase().replace(/:\/*$/, '');
39
+ if (!/^[a-z][a-z0-9+.-]{1,63}$/.test(s) || ['http', 'https', 'intent', 'file', 'javascript', 'data'].includes(s)) {
40
+ throw new UsageError(`"${value}" can't be your app's custom scheme. Use a short lowercase word, for example acmeshop.`);
41
+ }
42
+ return s;
43
+ }
44
+
45
+ /** A scheme guess from the Android package or app name: "com.acme.shop" → "acmeshop". */
46
+ export function suggestScheme({ scheme, androidPackage, appName } = {}) {
47
+ if (scheme) return scheme;
48
+ const parts = androidPackage?.split('.') ?? [];
49
+ const fromPkg = (parts.length > 1 && /^(com|org|net|io|dev|co|in|app|me)$/.test(parts[0]) ? parts.slice(1) : parts).join('').slice(0, 30);
50
+ for (const c of [fromPkg, appName]) {
51
+ const s = String(c ?? '').toLowerCase().replace(/[^a-z0-9]/g, '');
52
+ if (/^[a-z][a-z0-9]{1,}$/.test(s) && !['app', 'android', 'mobile', 'example'].includes(s)) return s;
53
+ }
54
+ return null;
55
+ }