@straitlink/cli 0.3.4 → 0.3.5

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
@@ -16,6 +16,7 @@ npx @straitlink/cli init # set up deep links
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
18
  npx @straitlink/cli test # key, engine and link domain: pass/fail (secret key in env)
19
+ npx @straitlink/cli app verify # your whole setup, each problem with its fix (secret key in env)
19
20
  npx @straitlink/cli events add purchase # print the code that tracks an event
20
21
  ```
21
22
 
@@ -82,6 +83,8 @@ Android (assetlinks.json)
82
83
  | `--key` | Also check the publishable key. Env: `STRAIT_PUBLISHABLE_KEY`. Left out, the key check is skipped |
83
84
  | `--json` | The reports as JSON, for CI and agents |
84
85
 
86
+ With a secret key (`STRAIT_SECRET_KEY` or `--secret-key`), `doctor` also runs [`strait app verify`](#whats-wrong-with-my-setup-strait-app-verify) and prints its checks under "Account"; `--host` is then optional. If those checks can't run (a refused or malformed key, no connection, the 6-a-minute limit) it says "Could not check" and exits `1`, never a silent pass. `--json` adds them as `verify`. If your CI sets `STRAIT_SECRET_KEY` and should check only the host, unset it for the doctor step (`env -u STRAIT_SECRET_KEY npx @straitlink/cli doctor --host …`).
87
+
85
88
  Exit codes: `0` no errors (warnings allowed), `1` a check failed, `2` usage problem. What a terminal can't see (intent filters, the Associated Domains entitlement) is checked by `doctor()` inside a debug build and by the [free tools](https://straitlink.in/tools/).
86
89
 
87
90
  ## Manage links: `strait links`
@@ -120,6 +123,30 @@ Your app's own setup (intent filters, Associated Domains): strait doctor --andro
120
123
 
121
124
  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
125
 
126
+ ## What's wrong with my setup? `strait app verify`
127
+
128
+ ```bash
129
+ export STRAIT_SECRET_KEY=st_live_… # Dashboard → Settings → Secret keys (read or full access)
130
+ npx @straitlink/cli app # the setup Strait has: apps, link domains and their files, SDKs seen
131
+ npx @straitlink/cli app verify # every check, with the fix for each problem
132
+ ```
133
+
134
+ ```
135
+ Strait app verify (checked 2026-11-09 17:44 UTC)
136
+
137
+ pass Android app details are complete
138
+ pass Android file served correctly on acme.strait.link
139
+ warn Your iPhone build runs a Strait SDK from before 5 Nov 2026
140
+ Its app opens don't say which SDK version sent them, so Strait can't tell what it misses.
141
+ Fix: Update the Strait SDK to its current version (Dashboard › Setup shows it) and ship a new build.
142
+ skip No webhooks set up
143
+
144
+ 2 pass, 1 warn, 0 fail, 0 unknown, 1 skip.
145
+ No failures. Check the warnings above.
146
+ ```
147
+
148
+ The same checks as Dashboard › Setup › Integration health, from the API (`GET /v1/app`, `GET /v1/app/verify`): your Android and iPhone app details, each link domain's `assetlinks.json` and `apple-app-site-association` (fetched live), whether your app has reported in the last 24 hours, whether its SDK is current, whether app opens from a link show their screen, and webhooks. Exit `0` when nothing failed, `1` when a check failed or could not run, or the API refused the key (so an AI agent or CI job can loop until it passes), `2` for a missing or non-secret key. `--json` prints the API answer as is (`{ ok, checkedAt, summary, checks: [{ id, status, title, detail, fix, host?, platform? }] }`). Nothing secret is printed: no keys, emails or webhook URLs.
149
+
123
150
  ## Track an event: `strait events add`
124
151
 
125
152
  ```bash
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@straitlink/cli",
3
- "version": "0.3.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.",
3
+ "version": "0.3.5",
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, app verify checks your whole setup with a fix for each problem, links creates and lists links and saves QR codes.",
5
5
  "keywords": [
6
6
  "strait",
7
7
  "deep-linking",
package/src/app.mjs ADDED
@@ -0,0 +1,106 @@
1
+ // `strait app` and `strait app verify` (docs/adr/0003): the workspace's app setup and the engine's checks of it
2
+ // (GET /v1/app, GET /v1/app/verify, engine ADR 0108), printed for people, or as the engine's JSON for agents.
3
+ // The engine owns every rule and every fix's wording; this file only prints them.
4
+ import { ApiError } from './links.mjs';
5
+
6
+ // Engine text can carry user-controlled names (hosts, workspace text): one printed line stays one line, with no
7
+ // terminal control characters and no bidi overrides that could make it read as something else.
8
+ const UNSAFE = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g;
9
+ const clean = (line) => String(line).replace(UNSAFE, (c) => (c === '\n' || c === '\r' || c === '\t' ? ' ' : ''));
10
+ const lines = (out) => out.map(clean).join('\n');
11
+ /** JSON for agents: as is, with DEL, C1 and bidi characters escaped (JSON.stringify escapes only those below U+0020). */
12
+ export const toJson = (x) => JSON.stringify(x, null, 2).replace(/[\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
13
+ const str = (x) => (typeof x === 'string' && x ? x : null);
14
+ const objs = (x) => (Array.isArray(x) ? x.filter((o) => o && typeof o === 'object') : []);
15
+ const day = (iso) => {
16
+ const t = Date.parse(str(iso) ?? '');
17
+ return Number.isFinite(t) ? `${new Date(t).toISOString().slice(0, 16).replace('T', ' ')} UTC` : '?';
18
+ };
19
+
20
+ /** `sub`: undefined (show the setup) or 'verify'; cli.mjs refuses anything else. @returns {Promise<{ text: string, code: number }>} */
21
+ export async function appCommand(sub, v, { call }) {
22
+ if (sub === undefined) {
23
+ const setup = await getJson(call, '/v1/app');
24
+ return { text: v.json ? toJson(setup) : formatSetup(setup), code: 0 };
25
+ }
26
+ const answer = await getVerify(call);
27
+ return { text: v.json ? toJson(answer) : formatVerify(answer), code: answer?.ok === true ? 0 : 1 };
28
+ }
29
+
30
+ /** A 200 that isn't JSON (a proxy page, a wrong --api-url) is an ApiError, not a crash. */
31
+ async function getJson(call, path) {
32
+ const res = await call('GET', path);
33
+ try {
34
+ return await res.json();
35
+ } catch {
36
+ throw new ApiError(`GET ${path}: the answer is not JSON. Is --api-url (STRAIT_API_URL) a Strait engine?`);
37
+ }
38
+ }
39
+
40
+ export const getVerify = (call) => getJson(call, '/v1/app/verify');
41
+
42
+ function formatSetup(s) {
43
+ const a = s?.android;
44
+ const i = s?.ios;
45
+ const out = ['Strait app setup', ''];
46
+ if (a) {
47
+ out.push(` Android ${str(a.packageName) ?? '(no package name)'}${str(a.scheme) ? ` scheme ${a.scheme}` : ''} ${a.appLinksReady ? 'App Links ready' : 'App Links NOT ready'}`);
48
+ for (const fp of Array.isArray(a.sha256Fingerprints) ? a.sha256Fingerprints : []) out.push(` SHA-256 ${fp}`);
49
+ } else out.push(' Android not set up');
50
+ if (i) {
51
+ const id = str(i.appId) ?? `${str(i.teamId) ?? '(no Team ID)'}.${str(i.bundleId) ?? '(no bundle ID)'}`;
52
+ out.push(` iPhone ${id}${str(i.appStoreId) ? ` App Store ${i.appStoreId}` : ''} ${i.universalLinksReady ? 'Universal Links ready' : 'Universal Links NOT ready'}`);
53
+ } else out.push(' iPhone not set up');
54
+
55
+ const domains = objs(s?.domains);
56
+ out.push('', `Link domains (files checked ${day(s?.filesCheckedAt)})`);
57
+ if (!domains.length) out.push(' none');
58
+ const PHONE = { android: 'Android', ios: 'iPhone' };
59
+ for (const d of domains) {
60
+ const files = objs(d.files).map((f) => `${PHONE[f.platform] ?? f.platform} file ${f.served === true ? 'served' : f.served === false ? 'NOT served' : 'not checked yet'}`);
61
+ out.push(` ${d.host} (${d.kind}) ${files.join(' · ') || 'no app file (no app set up)'}`);
62
+ }
63
+
64
+ out.push('', `SDK seen (last ${s?.sdkWindow?.days ?? 30} days)`);
65
+ for (const [p, label] of [['android', 'Android'], ['ios', 'iPhone'], ['web', 'Web']]) {
66
+ const x = s?.sdk?.[p];
67
+ const what = !x ? 'not seen' : `${str(x.reported) ?? 'no SDK version sent'}, last app open ${day(x.lastSeenAt)}`;
68
+ out.push(` ${label.padEnd(8)} ${what}`);
69
+ }
70
+ out.push('', `Change these in ${str(s?.setupUrl) ?? 'Dashboard › Setup'}. Check everything: strait app verify`);
71
+ return lines(out);
72
+ }
73
+
74
+ /** One line per check; under anything not passed, what is wrong and the fix. */
75
+ export function verifyLines(answer) {
76
+ const out = [];
77
+ for (const c of objs(answer?.checks)) {
78
+ out.push(` ${String(c.status).padEnd(7)} ${c.title}`);
79
+ if (c.status === 'pass' || c.status === 'skip') continue;
80
+ if (c.detail) out.push(` ${c.detail}`);
81
+ if (c.fix) out.push(` Fix: ${c.fix}`);
82
+ }
83
+ return out;
84
+ }
85
+
86
+ /** The verdict line: failures first, then checks that could not run. */
87
+ export function verifyVerdict(answer) {
88
+ const n = answer?.summary ?? {};
89
+ if (n.fail) return `${n.fail} check${n.fail > 1 ? 's' : ''} failed. Fix them, then run strait app verify again.`;
90
+ if (n.unknown) return `${n.unknown} check${n.unknown > 1 ? 's' : ''} could not run. Run strait app verify again in a minute.`;
91
+ return answer?.ok === true ? (n.warn ? 'No failures. Check the warnings above.' : 'All checks passed.') : 'Not all checks passed.';
92
+ }
93
+
94
+ function formatVerify(answer) {
95
+ const n = answer?.summary ?? {};
96
+ const out = [`Strait app verify (checked ${day(answer?.checkedAt)})`, '', ...verifyLines(answer), ''];
97
+ out.push(`${n.pass ?? 0} pass, ${n.warn ?? 0} warn, ${n.fail ?? 0} fail, ${n.unknown ?? 0} unknown, ${n.skip ?? 0} skip.`, verifyVerdict(answer));
98
+ return lines(out);
99
+ }
100
+
101
+ /** `strait doctor`'s account block: the verify answer, or why it could not run (never a silent pass). */
102
+ export function formatAccount(answer) {
103
+ const head = 'Account (strait app verify)';
104
+ if (answer?.error) return lines([head, ` Could not check: ${answer.error}`]);
105
+ return lines([head, ...verifyLines(answer), '', verifyVerdict(answer)]);
106
+ }
package/src/cli.mjs CHANGED
@@ -11,6 +11,7 @@ import { ApiError, DEFAULT_API_URL, apiClient, linksCommand, secretKey } from '.
11
11
  import { CLI_VERSION } from './version.mjs';
12
12
  import { formatSelfTest, runSelfTest } from './selftest.mjs';
13
13
  import { eventsCommand } from './events.mjs';
14
+ import { appCommand, formatAccount, getVerify, toJson } from './app.mjs';
14
15
 
15
16
  const OPTIONS = {
16
17
  init: {
@@ -28,6 +29,8 @@ const OPTIONS = {
28
29
  android: { type: 'string' },
29
30
  ios: { type: 'string' },
30
31
  cert: { type: 'string', multiple: true },
32
+ 'secret-key': { type: 'string' },
33
+ 'api-url': { type: 'string' },
31
34
  json: { type: 'boolean', default: false },
32
35
  },
33
36
  links: {
@@ -53,17 +56,24 @@ const OPTIONS = {
53
56
  events: {
54
57
  platform: { type: 'string' },
55
58
  },
59
+ app: {
60
+ 'secret-key': { type: 'string' },
61
+ 'api-url': { type: 'string' },
62
+ json: { type: 'boolean', default: false },
63
+ },
56
64
  };
57
65
 
58
66
  export const HELP = `strait: set up and check Strait deep links, and manage links
59
67
 
60
68
  Usage
61
69
  strait init [dir] [options] Write the Strait integration into your app
62
- strait doctor --host <host> [options] Check your link host is set up for your app
70
+ strait doctor [--host <host>] [options] Check your link host (and with a secret key, your whole setup)
63
71
  strait links create <slug> <url> [options] Create a link
64
72
  strait links list [options] List links
65
73
  strait links qr <slug> [options] Save a link's QR code
66
74
  strait test [options] Check your key, the engine and your link domain
75
+ strait app [options] Show your app setup as Strait sees it
76
+ strait app verify [options] Check your whole setup: each problem with its fix
67
77
  strait events add <name> [dir] [options] Print ready-to-paste code that tracks an event
68
78
 
69
79
  init options
@@ -82,6 +92,9 @@ doctor options (the same checks and messages as doctor() in the SDKs)
82
92
  --ios <bundleId> iPhone bundle ID, e.g. com.acme.shop
83
93
  --cert <sha256> Android signing certificate fingerprint (repeatable)
84
94
  --key <st_pub_live_…> Also check the publishable key (env STRAIT_PUBLISHABLE_KEY)
95
+ --secret-key <st_live_…> Also run strait app verify (env STRAIT_SECRET_KEY); --host is then optional.
96
+ If it can't run (refused key, no connection), doctor says so and exits 1
97
+ --api-url <url> Engine for those checks (env STRAIT_API_URL, default https://strait.link)
85
98
  --json Print the reports as JSON
86
99
  With neither --android nor --ios, both files are checked and the apps they list are shown.
87
100
  Exit 1 when a check fails.
@@ -102,6 +115,14 @@ test options
102
115
  workspace's link domain serves apple-app-site-association and assetlinks.json. Exit 1
103
116
  when a check fails (so it can run in CI), 2 for a missing or wrong kind of key.
104
117
 
118
+ app options
119
+ --secret-key, --api-url As for links (env STRAIT_SECRET_KEY, STRAIT_API_URL); a read or full key
120
+ --json Print the API answer as JSON (for agents and CI)
121
+ strait app prints the apps, link domains and SDKs Strait has for your workspace. strait app verify
122
+ runs the same checks as Dashboard › Setup › Integration health and prints each with its fix.
123
+ Exit 1 when a check fails or could not run (or the API refuses the key), 2 for a missing or
124
+ non-secret key.
125
+
105
126
  events options
106
127
  --platform <name> react-native | flutter | ios | android | web (detected by default)
107
128
 
@@ -114,6 +135,7 @@ Examples
114
135
  npx @straitlink/cli doctor --host acme.strait.link --android com.acme.shop --ios com.acme.shop
115
136
  STRAIT_SECRET_KEY=st_live_… npx @straitlink/cli links create summer https://acme.com/summer
116
137
  STRAIT_SECRET_KEY=st_live_… npx @straitlink/cli test
138
+ STRAIT_SECRET_KEY=st_live_… npx @straitlink/cli app verify
117
139
  npx @straitlink/cli events add purchase
118
140
  `;
119
141
 
@@ -146,7 +168,7 @@ export async function main(argv, io = {}) {
146
168
  }
147
169
  if (cmd !== 'init') {
148
170
  try {
149
- return await { doctor, links, test: selfTest, events }[cmd](args, { out, err, env, io });
171
+ return await { doctor, links, test: selfTest, events, app }[cmd](args, { out, err, env, io });
150
172
  } catch (e) {
151
173
  if (e instanceof UsageError) {
152
174
  err.write(`strait: ${e.message}\n`);
@@ -223,15 +245,41 @@ async function terminalAsker(input, output) {
223
245
 
224
246
  async function doctor({ values: v }, { out, env, io }) {
225
247
  const hostIn = v.host ?? env.STRAIT_LINK_HOST;
226
- if (!hostIn) throw new UsageError('Missing --host: your link domain from Dashboard → Get started, e.g. --host acme.strait.link.');
227
- const host = normalizeHost(hostIn);
228
- const keyIn = v.key ?? env.STRAIT_PUBLISHABLE_KEY;
229
- const key = keyIn ? String(keyIn).trim() : undefined;
230
- const result = await runDoctor({ host, key, android: v.android?.trim(), ios: v.ios?.trim(), certs: v.cert ?? [], fetch: io.fetch ?? fetch });
231
- out.write((v.json ? JSON.stringify(result, null, 2) : formatDoctor(result)) + '\n');
248
+ const secretIn = v['secret-key'] ?? env.STRAIT_SECRET_KEY;
249
+ if (!hostIn && !secretIn) throw new UsageError('Missing --host: your link domain from Dashboard → Get started, e.g. --host acme.strait.link. Or set STRAIT_SECRET_KEY to check your whole setup.');
250
+ const fetchImpl = io.fetch ?? fetch;
251
+ let result = { ok: true };
252
+ if (hostIn) {
253
+ const keyIn = v.key ?? env.STRAIT_PUBLISHABLE_KEY;
254
+ const key = keyIn ? String(keyIn).trim() : undefined;
255
+ result = await runDoctor({ host: normalizeHost(hostIn), key, android: v.android?.trim(), ios: v.ios?.trim(), certs: v.cert ?? [], fetch: fetchImpl });
256
+ }
257
+ if (secretIn) {
258
+ // A secret key adds the engine's checks (docs/adr/0003). If they can't run, say so and fail: never a silent pass.
259
+ try {
260
+ const call = apiClient({ apiUrl: v['api-url'] ?? env.STRAIT_API_URL ?? DEFAULT_API_URL, key: secretKey(secretIn), fetch: fetchImpl, userAgent: `strait-cli/${CLI_VERSION}` });
261
+ result.verify = await getVerify(call);
262
+ } catch (e) {
263
+ if (!(e instanceof ApiError || e instanceof UsageError)) throw e;
264
+ result.verify = { error: e.message };
265
+ }
266
+ if (result.verify?.ok !== true) result.ok = false;
267
+ }
268
+ const text = v.json ? toJson(result) : [hostIn && formatDoctor(result), secretIn && formatAccount(result.verify)].filter(Boolean).join('\n\n');
269
+ out.write(text + '\n');
232
270
  return result.ok ? 0 : 1;
233
271
  }
234
272
 
273
+ async function app({ values: v, positionals }, { out, env, io }) {
274
+ const [sub, extra] = positionals;
275
+ if (extra !== undefined || (sub !== undefined && sub !== 'verify'))
276
+ throw new UsageError(`Unknown "${extra ?? sub}". strait app shows your setup; strait app verify checks it (--json for agents).`);
277
+ const call = apiClient({ apiUrl: v['api-url'] ?? env.STRAIT_API_URL ?? DEFAULT_API_URL, key: secretKey(v['secret-key'] ?? env.STRAIT_SECRET_KEY), fetch: io.fetch ?? fetch, userAgent: `strait-cli/${CLI_VERSION}` });
278
+ const { text, code } = await appCommand(sub, v, { call });
279
+ out.write(text + '\n');
280
+ return code;
281
+ }
282
+
235
283
  async function selfTest({ values: v, positionals }, { out, env, io }) {
236
284
  if (positionals.length) throw new UsageError(`strait test takes no arguments ("${positionals[0]}"). Options: --secret-key, --api-url, --json.`);
237
285
  const key = secretKey(v['secret-key'] ?? env.STRAIT_SECRET_KEY);
package/src/doctor.mjs CHANGED
@@ -148,7 +148,8 @@ export function formatDoctor(result) {
148
148
  if (keyLines.length) out.push('', 'Key', ...keyLines.map(line));
149
149
  for (const p of reports) out.push('', LABEL[p], ...result[p].findings.filter((f) => !f.id.startsWith('key.')).map(line));
150
150
  const warned = reports.some((p) => result[p].findings.some((f) => f.severity === 'warn'));
151
- out.push('', !result.ok ? 'Fix the errors above, then run strait doctor again.' : warned ? 'No errors. Check the warnings above.' : 'All checks passed.');
151
+ // The host's own verdict: a secret key's account checks (cli.mjs) have their own.
152
+ out.push('', reports.some((p) => !result[p].ok) ? 'Fix the errors above, then run strait doctor again.' : warned ? 'No errors. Check the warnings above.' : 'All checks passed.');
152
153
  out.push("Intent filters and the Associated Domains entitlement aren't visible from here: call doctor() in a debug build, or see https://straitlink.in/tools/.");
153
154
  return out.join('\n');
154
155
  }
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.3.4';
2
+ export const CLI_VERSION = '0.3.5';