@straitlink/cli 0.2.2 → 0.3.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/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:
@@ -64,7 +66,7 @@ Exit codes: `0` done, `2` usage problem (message on stderr), `1` unexpected erro
64
66
  npx @straitlink/cli doctor --host acme.strait.link --android com.acme.shop --ios com.acme.shop
65
67
  ```
66
68
 
67
- Runs the same checks, with the same finding ids and wording, as `doctor()` in the Strait SDKs (React Native 0.9.3, Swift 0.9.4, Android 0.8.4, Flutter 0.9.4; contract B23, the 43 shared cases in `test/doctor-vectors.json`). It fetches `https://<host>/.well-known/assetlinks.json` and `apple-app-site-association` once each and tells you whether your package, bundle ID and (with `--cert`) signing certificate are listed, with the fix for each problem:
69
+ Runs the same checks, with the same finding ids and wording, as `doctor()` in the Strait SDKs (React Native, Flutter, iOS, Android and Unity; contract B23, the shared cases in `test/doctor-vectors.json`). It fetches `https://<host>/.well-known/assetlinks.json` and `apple-app-site-association` once each and tells you whether your package, bundle ID and (with `--cert`) signing certificate are listed, with the fix for each problem:
68
70
 
69
71
  ```
70
72
  Android (assetlinks.json)
@@ -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.2",
3
+ "version": "0.3.1",
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/doctor.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  // `strait doctor`: the SDK setup check (contract B23, shared-spec docs/adr/0002-sdk-doctor.md) from a
2
2
  // terminal. `doctorFindings` is a line-for-line port of strait-sdk-react-native 0.9.3 src/doctor.ts:
3
- // same finding ids, severities and wording as every SDK (test/doctor-vectors.json, 43 shared cases).
3
+ // same finding ids, severities and wording as every SDK (test/doctor-vectors.json, the shared cases).
4
4
 
5
5
  /** The endpoint's host when it is an `https://` URL with a host, else null. */
6
6
  export function doctorHost(endpoint) {
@@ -61,14 +61,22 @@ export function doctorFindings(input) {
61
61
  return { ok: !findings.some((f) => f.severity === 'error'), findings };
62
62
  }
63
63
 
64
+ /**
65
+ * A Strait link domain answers 404 {"error":"not configured"} until Apps & stores has this platform's app.
66
+ * Compared byte for byte (what the engine's well-known route sends), so every SDK agrees on any other body.
67
+ */
68
+ const NOT_CONFIGURED = '{"error":"not configured"}';
69
+ const notConfigured = (file) => file.status === 404 && file.body === NOT_CONFIGURED;
70
+
64
71
  function hostFindings(input, host, file) {
65
72
  const android = input.platform === 'android';
66
73
  if (file.status === undefined)
67
74
  return [err('host.unreachable', `Can't reach ${host}`, 'Check that endpoint is your link domain (Dashboard → Get started) and that this device is online.')];
68
75
  const apps = file.status === 200 ? listedApps(input.platform, file.body ?? '') : null;
69
- if (!apps)
76
+ if (!apps && !notConfigured(file))
70
77
  return [err('host.file_missing', `${host} doesn't serve ${android ? 'assetlinks.json' : 'apple-app-site-association'}`, 'Use your Strait link domain as endpoint (Dashboard → Get started); Strait serves this file there.')];
71
- if (!apps.size) return [err('app.none', `No ${android ? 'Android' : 'iOS'} app is set up for ${host}`, 'Add your app in Dashboard → Apps & stores.')];
78
+ if (!apps?.size)
79
+ return [err('app.none', `No ${android ? 'Android' : 'iOS'} app is set up for ${host}`, `Fill in Dashboard → Apps & stores (${android ? 'package name and SHA-256 fingerprint' : 'Team ID and bundle ID'}), wait a minute, then check again. Already done? Make sure ${host} is your link domain from Dashboard → Get started.`)];
72
80
 
73
81
  const listed = [...apps.keys()].join(', ');
74
82
  const app = input.appId?.trim();
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
- rnVersion: '0.10.2',
26
- flutterVersion: '0.9.7',
27
- androidVersion: '0.8.4',
25
+ version: '0.11.0', // Swift (Unity is not written by this CLI)
26
+ rnVersion: '0.11.0',
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/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.2';
2
+ export const CLI_VERSION = '0.3.1';