@straitlink/cli 0.1.1 → 0.1.2

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
@@ -28,7 +28,7 @@ npx @straitlink/cli init
28
28
  | Flutter | `lib/links.dart` |
29
29
  | Android | `app/src/main/java/<package>/StraitLinks.kt` |
30
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]`.
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 Associated Domains entry, what to fill in under Dashboard → Apps & stores, 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
32
 
33
33
  It never edits your existing files and never overwrites an earlier integration file unless you pass `--force`.
34
34
 
@@ -48,7 +48,7 @@ From a checkout of this repo: `node bin/strait.mjs init ../my-app`, or `npm link
48
48
  |---|---|
49
49
  | `--key` | Publishable key (Dashboard → Get started). Env: `STRAIT_PUBLISHABLE_KEY` |
50
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`) |
51
+ | `--scheme` | Custom scheme: the one you typed in Dashboard → Apps & stores. Taken from `app.json`, or guessed from the package name (`com.acme.shop` → `acmeshop`); a guess is marked "(guessed from …)" in the output |
52
52
  | `--framework` | `react-native`, `expo`, `flutter` or `android`, to skip detection |
53
53
  | `--dry-run` | Print the files and steps; write nothing |
54
54
  | `--force` | Replace an existing integration file |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@straitlink/cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "npx @straitlink/cli init: set up Strait deep links in a React Native, Expo, Flutter or Android app.",
5
5
  "keywords": [
6
6
  "strait",
package/src/cli.mjs CHANGED
@@ -15,7 +15,8 @@ Usage
15
15
  Options
16
16
  --key <st_pub_live_…> Publishable key (Dashboard → Get started)
17
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)
18
+ --scheme <scheme> Your app's custom scheme, the one in Dashboard → Apps & stores,
19
+ e.g. acmeshop (guessed from the package name if left out)
19
20
  --framework <name> react-native | expo | flutter | android (detected by default)
20
21
  --dry-run Print what would be written; write nothing
21
22
  --force Replace an existing integration file
@@ -84,7 +85,8 @@ export async function main(argv, io = {}) {
84
85
  host = host ?? (await askValid(ask, 'Link host (e.g. acme.strait.link): ', normalizeHost, err));
85
86
  if (!scheme) {
86
87
  const guess = suggestScheme(detectProject(dir) ?? {});
87
- scheme = await askValid(ask, `Custom scheme${guess ? ` [${guess}]` : ''}: `, (a) => normalizeScheme(a || guess || ''), err);
88
+ const hint = guess ? ` [${guess}, guessed from the package name]` : '';
89
+ scheme = await askValid(ask, `Custom scheme (the one in Dashboard → Apps & stores)${hint}: `, (a) => normalizeScheme(a || guess || ''), err);
88
90
  }
89
91
  } finally {
90
92
  ask.close?.();
package/src/init.mjs CHANGED
@@ -31,12 +31,15 @@ export function planInit({ dir, key, host, scheme, framework, force = false, ins
31
31
  const chosen = scheme ?? suggestScheme(project);
32
32
  if (!chosen) throw new UsageError("Couldn't guess your app's custom scheme. Pass --scheme, for example --scheme acmeshop.");
33
33
  const opts = { key: k, host: h, scheme: normalizeScheme(chosen) };
34
+ // Said out loud in the output: the developer may have made up a different scheme in Apps & stores.
35
+ const schemeGuessedFrom =
36
+ scheme || project.scheme ? null : suggestScheme({ androidPackage: project.androidPackage }) ? `the package name ${project.androidPackage}` : `the app name ${project.appName}`;
34
37
  const files = integrationFiles(project, opts).map((f) => {
35
38
  const abs = join(root, f.path);
36
39
  return { ...f, abs, exists: existsSync(abs) };
37
40
  });
38
41
  const conflicts = force ? [] : files.filter((f) => f.exists).map((f) => f.path);
39
- return { project, opts, files, steps: nextSteps(project, opts, installSource), conflicts };
42
+ return { project, opts, schemeGuessedFrom, files, steps: nextSteps(project, opts, installSource), conflicts };
40
43
  }
41
44
 
42
45
  function emptyProject(dir) {
@@ -62,10 +65,27 @@ export function formatPlan(plan, { dryRun = false } = {}) {
62
65
  const out = [];
63
66
  const facts = [project.label, project.androidPackage && `package ${project.androidPackage}`, project.expoRouter && 'Expo Router', project.typescript && 'TypeScript'].filter(Boolean);
64
67
  out.push(`Found: ${facts.join(' · ')}`);
65
- out.push(`Link host: ${opts.host} · scheme: ${opts.scheme}://`);
68
+ out.push(`Link host: ${opts.host} · scheme: ${opts.scheme}://${plan.schemeGuessedFrom ? ` (guessed from ${plan.schemeGuessedFrom})` : ''}`);
69
+ if (plan.schemeGuessedFrom) {
70
+ out.push(
71
+ 'If you made up your own scheme (Custom scheme in Dashboard → Apps & stores), ' +
72
+ (dryRun
73
+ ? 'run this again with --scheme yourscheme, so every line below uses it.'
74
+ : `use it in place of ${opts.scheme} in the steps below: the files written don't contain the scheme, so you don't need --force. ` +
75
+ 'To reprint the steps with it, run this again with --scheme yourscheme --dry-run.'),
76
+ );
77
+ }
66
78
  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}`);
79
+ out.push(
80
+ !dryRun
81
+ ? 'Created:'
82
+ : plan.conflicts.length
83
+ ? `Dry run: nothing written. A real run would stop: ${plan.conflicts.join(', ')} already exists (add --force to replace it). With --force it would write:`
84
+ : 'Dry run: nothing written. These files would be created:',
85
+ );
86
+ // A conflict is a file the real run refuses to overwrite (no --force): never call it replaced.
87
+ const label = (f) => (plan.conflicts.includes(f.path) ? ' (already exists: kept unless --force)' : f.exists ? ' (replaced)' : '');
88
+ for (const f of files) out.push(` ${f.path}${label(f)} ${f.why}`);
69
89
  if (dryRun) {
70
90
  for (const f of files) {
71
91
  out.push('');
package/src/install.mjs CHANGED
@@ -16,12 +16,15 @@ export const INSTALL = {
16
16
  /** The source for one registry: `source` as one word applies to all three. */
17
17
  export const sourceFor = (src, registry) => (typeof src.source === 'string' ? src.source : src.source[registry]);
18
18
 
19
- /** SDK versions the generated code is written for (site.config.json packages). */
19
+ /**
20
+ * SDK versions the generated code is written for. Must equal site/site.config.json `packages`
21
+ * (the one source of truth; test/install.test.mjs fails if they differ). Bump both together.
22
+ */
20
23
  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',
24
+ version: '0.9.3', // Swift (Unity is not written by this CLI)
25
+ rnVersion: '0.9.2',
26
+ flutterVersion: '0.9.3',
27
+ androidVersion: '0.8.1',
25
28
  };
26
29
 
27
30
  export const PACKAGES = {
package/src/templates.mjs CHANGED
@@ -84,7 +84,7 @@ class LifecycleFeed with WidgetsBindingObserver {
84
84
  Stream<AppLifecycle> get stream => _ctl.stream;
85
85
  LifecycleFeed() { WidgetsBinding.instance.addObserver(this); }
86
86
  @override
87
- void didChangeAppLifecycleState(AppLifecycleState s) => _ctl.add(switch (s) {
87
+ void didChangeAppLifecycleState(AppLifecycleState state) => _ctl.add(switch (state) {
88
88
  AppLifecycleState.resumed => AppLifecycle.active,
89
89
  AppLifecycleState.inactive => AppLifecycle.inactive,
90
90
  _ => AppLifecycle.background,
@@ -293,6 +293,10 @@ export default function RootLayout() {
293
293
  return <Stack />;
294
294
  }`;
295
295
 
296
+ // ops/IOS-STATUS-1022.md: iPhone links are live; install matching is opt-in (dashboard "iPhone installs", off until chosen).
297
+ const IPHONE_INSTALLS =
298
+ 'iPhone install matching is opt-in: choose a method in Dashboard → Settings → iPhone installs (device matching, paste or both; off until you choose) to land people on the link\'s screen after an App Store install.';
299
+
296
300
  const keytool = (fw) =>
297
301
  fw === 'expo'
298
302
  ? 'keytool -list -v -keystore android/app/debug.keystore -storepass android | grep SHA256 # local builds; EAS builds: eas credentials'
@@ -328,7 +332,7 @@ export function nextSteps(project, opts, installSrc) {
328
332
  // 2. Start it
329
333
  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
334
  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 });
335
+ steps.push({ title: 'Add link settings to app.json (merge with what you have)', code: expoAppJson(opts, project), lang: 'json', body: `${project.dynamicConfig ? 'Your config is app.config.js/ts, so copy these keys into it by hand. ' : ''}associatedDomains opens links in your iPhone app. ${IPHONE_INSTALLS}` });
332
336
  if (project.expoRouter) steps.push({ title: 'Route links in app/_layout.tsx', code: EXPO_LAYOUT, lang: 'tsx' });
333
337
  else steps.push({ title: 'Load links at startup', body: "Add `import './links';` to your entry file." });
334
338
  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' });
@@ -357,19 +361,24 @@ export function nextSteps(project, opts, installSrc) {
357
361
  code: done ? undefined : [filtersDone ? null : manifestXml(opts), needsFlutterMeta ? flutterMeta : null].filter(Boolean).join('\n'),
358
362
  lang: 'xml',
359
363
  });
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.' : ''}` });
364
+ if (fw !== 'android') {
365
+ steps.push({
366
+ title: 'iPhone',
367
+ body: `In Xcode add Associated Domains → applinks:${host}, and ${scheme} under Info → URL Types.${fw === 'flutter' ? ' Set FlutterDeepLinkingEnabled = NO in Info.plist.' : ''} Links then open your app on the right screen. ${IPHONE_INSTALLS}`,
368
+ });
369
+ }
361
370
  }
362
371
 
363
372
  // 4. Dashboard + verification files
364
373
  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.`,
374
+ title: 'Fill in Dashboard → Apps & stores',
375
+ 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, your Apple Team ID and bundle ID.`,
367
376
  code: keytool(fw),
368
377
  lang: 'bash',
369
378
  });
370
379
  steps.push({
371
380
  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).',
381
+ body: 'Each returns 404 until the matching Apps & stores fields are filled in. assetlinks.json lets Android open https links in the app directly; apple-app-site-association does the same on iPhone.',
373
382
  code: `curl https://${host}/.well-known/assetlinks.json\ncurl https://${host}/.well-known/apple-app-site-association`,
374
383
  lang: 'bash',
375
384
  });
package/src/validate.mjs CHANGED
@@ -4,12 +4,17 @@
4
4
  export class UsageError extends Error {}
5
5
 
6
6
  const PUB_KEY = /^st_pub_(live|test)_[A-Za-z0-9]{8,}$/;
7
- const SECRET_KEY = /^st_(live|test)_/;
7
+ const SECRET_KEY = /st_(live|test)_/; // anywhere: quotes, KEY=…, "Bearer …" (a publishable key never contains it)
8
8
  const LABEL = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
9
+ // Stand-ins seen in our docs and screenshots (site/scripts/dashboard-shots/mock-supabase.ts). Real keys are 32 hex.
10
+ const EXAMPLE_BODIES = new Set(['8fK2mQx7Lp4RtZ']);
9
11
 
10
- /** A publishable key (st_pub_live_…). A secret key is refused: it must never ship in an app. */
12
+ /**
13
+ * A publishable key (st_pub_live_…). A secret key is refused: it must never ship in an app.
14
+ * Nothing the person typed is echoed back except an st_pub_ key, which is safe to show.
15
+ */
11
16
  export function normalizeKey(value) {
12
- const k = String(value ?? '').trim();
17
+ let k = String(value ?? '').trim().replace(/^['"]+|['"]+$/g, '');
13
18
  if (!k) throw new UsageError('A publishable key is required (--key st_pub_live_…). Find it in Dashboard → Get started.');
14
19
  if (SECRET_KEY.test(k)) {
15
20
  throw new UsageError(
@@ -17,8 +22,21 @@ export function normalizeKey(value) {
17
22
  'If the secret key was pasted somewhere public, rotate it in Dashboard → Settings → Secret keys.',
18
23
  );
19
24
  }
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;
25
+ const m = /^st_pub_(live|test)_(.*)$/.exec(k);
26
+ const where = (mode = 'live') => `Copy your real one from Dashboard → Get started (the Publishable key box): st_pub_${mode}_ followed by 32 letters and numbers.`;
27
+ if (!m) throw new UsageError(`That is not a publishable key. ${where()}`);
28
+ const [, mode] = m;
29
+ let rest = m[2].replace(/\.$/, ''); // a full stop copied from a sentence
30
+ const shortened = /(?:…|\.\.)$/.test(rest);
31
+ rest = rest.replace(/(?:…|\.)+$/, '');
32
+ const bracketed = /^<(.*)>$/.exec(rest)?.[1];
33
+ if (bracketed != null && /^[A-Za-z0-9]{8,}$/.test(bracketed)) throw new UsageError(`Remove the < > around your key: st_pub_${mode}_${bracketed}.`);
34
+ if (rest === '' || bracketed != null || /your/i.test(rest) || /^x{3,}$/i.test(rest) || EXAMPLE_BODIES.has(rest)) {
35
+ throw new UsageError(`"${k}" is the example key from the docs, not your key. ${where(mode)}`);
36
+ }
37
+ const key = `st_pub_${mode}_${rest}`;
38
+ if (shortened || !PUB_KEY.test(key)) throw new UsageError(`"${k}" looks cut off. ${where(mode)}`);
39
+ return key;
22
40
  }
23
41
 
24
42
  /** "https://Acme.strait.link/summer" → "acme.strait.link". */