@straitlink/cli 0.3.1 → 0.3.3

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
@@ -34,7 +34,7 @@ npx @straitlink/cli events add purchase # print the code tha
34
34
 
35
35
  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]`.
36
36
 
37
- It never edits your existing files and never overwrites an earlier integration file unless you pass `--force`.
37
+ It edits one existing file, and only when needed: in a Flutter or Android (Kotlin) app it adds `<uses-permission android:name="android.permission.INTERNET" />` to `AndroidManifest.xml` if it isn't there. Flutter's own template only has it in debug builds, so without it a release build can't reach Strait and no link or install is matched. Everything else it prints for you to add. It never overwrites an earlier integration file unless you pass `--force`, and `--dry-run` shows the manifest line without writing it.
38
38
 
39
39
  ## Use
40
40
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@straitlink/cli",
3
- "version": "0.3.1",
3
+ "version": "0.3.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.",
5
5
  "keywords": [
6
6
  "strait",
package/src/init.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  // `strait init`: detect the app, collect key + host + scheme, write the
2
2
  // integration file(s), print the platform steps. Pure planning is separated from
3
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';
4
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
5
+ import { dirname, join, relative, resolve } from 'node:path';
6
6
  import { FRAMEWORKS, LABELS, describeFolder, detectProject } from './detect.mjs';
7
7
  import { INSTALL } from './install.mjs';
8
8
  import { integrationFiles, nextSteps } from './templates.mjs';
@@ -11,7 +11,7 @@ import { UsageError, normalizeHost, normalizeKey, normalizeScheme, suggestScheme
11
11
  /**
12
12
  * Works out everything without touching the disk.
13
13
  * @param {object} o { dir, key, host, scheme, framework, force }
14
- * @returns {{ project, opts, files: Array<{path, abs, contents, why, exists}>, steps, conflicts: string[] }}
14
+ * @returns {{ project, opts, files: Array<{path, abs, contents, why, exists}>, edits: Array<{path, abs, contents, why, line}>, steps, conflicts: string[] }}
15
15
  */
16
16
  export function planInit({ dir, key, host, scheme, framework, force = false, installSource = INSTALL }) {
17
17
  const root = resolve(dir ?? '.');
@@ -39,7 +39,32 @@ export function planInit({ dir, key, host, scheme, framework, force = false, ins
39
39
  return { ...f, abs, exists: existsSync(abs) };
40
40
  });
41
41
  const conflicts = force ? [] : files.filter((f) => f.exists).map((f) => f.path);
42
- return { project, opts, schemeGuessedFrom, files, steps: nextSteps(project, opts, installSource), conflicts };
42
+ return { project, opts, schemeGuessedFrom, files, edits: manifestEdits(project, root), steps: nextSteps(project, opts, installSource), conflicts };
43
+ }
44
+
45
+ // Flutter's template declares INTERNET only in the debug/profile manifests and an Android Studio
46
+ // template not at all, so a release build can't reach Strait (QA 9 Nov 2026). React Native's and
47
+ // Expo's templates already have it.
48
+ const INTERNET = '<uses-permission android:name="android.permission.INTERNET" />';
49
+
50
+ function manifestEdits(project, root) {
51
+ if (!['flutter', 'android'].includes(project.framework) || !project.manifestPath) return [];
52
+ const contents = addInternetPermission(readFileSync(project.manifestPath, 'utf8'));
53
+ if (!contents) return [];
54
+ const path = relative(root, project.manifestPath).split('\\').join('/');
55
+ return [{ path, abs: project.manifestPath, contents, line: INTERNET, why: 'adds the INTERNET permission release builds need' }];
56
+ }
57
+
58
+ /** The manifest with INTERNET added right after <manifest …>, or null when it already has it (or isn't a manifest). */
59
+ export function addInternetPermission(text) {
60
+ const live = text.replace(/<!--[\s\S]*?-->/g, (c) => ' '.repeat(c.length)); // same length: indexes still match text
61
+ if (/<uses-permission\b[^>]*android:name\s*=\s*"android\.permission\.INTERNET"/.test(live)) return null;
62
+ const open = /<manifest\b[^>]*>/.exec(live);
63
+ if (!open) return null;
64
+ const eol = text.includes('\r\n') ? '\r\n' : '\n';
65
+ const at = open.index + open[0].length;
66
+ const pad = /\n([ \t]*)\S/.exec(live.slice(at))?.[1] || ' '; // the next element's indent
67
+ return `${text.slice(0, at)}${eol}${pad}${INTERNET}${text.slice(at)}`;
43
68
  }
44
69
 
45
70
  function emptyProject(dir) {
@@ -53,7 +78,8 @@ export function writeFiles(plan) {
53
78
  mkdirSync(dirname(f.abs), { recursive: true });
54
79
  writeFileSync(f.abs, f.contents);
55
80
  }
56
- return plan.files.map((f) => f.path);
81
+ for (const e of plan.edits ?? []) writeFileSync(e.abs, e.contents);
82
+ return [...plan.files, ...(plan.edits ?? [])].map((f) => f.path);
57
83
  }
58
84
 
59
85
  // ---------- output ----------
@@ -86,12 +112,22 @@ export function formatPlan(plan, { dryRun = false } = {}) {
86
112
  // A conflict is a file the real run refuses to overwrite (no --force): never call it replaced.
87
113
  const label = (f) => (plan.conflicts.includes(f.path) ? ' (already exists: kept unless --force)' : f.exists ? ' (replaced)' : '');
88
114
  for (const f of files) out.push(` ${f.path}${label(f)} ${f.why}`);
115
+ if (plan.edits?.length) {
116
+ out.push('');
117
+ out.push(dryRun ? 'Would edit:' : 'Edited:');
118
+ for (const e of plan.edits) out.push(` ${e.path} ${e.why}`);
119
+ }
89
120
  if (dryRun) {
90
121
  for (const f of files) {
91
122
  out.push('');
92
123
  out.push(`--- ${f.path}`);
93
124
  out.push(f.contents.trimEnd());
94
125
  }
126
+ for (const e of plan.edits ?? []) {
127
+ out.push('');
128
+ out.push(`--- ${e.path}`);
129
+ out.push(`+ ${e.line}`);
130
+ }
95
131
  }
96
132
  out.push('');
97
133
  out.push('Next steps:');
package/src/install.mjs CHANGED
@@ -22,10 +22,10 @@ export const sourceFor = (src, registry) => (typeof src.source === 'string' ? sr
22
22
  * `strait events add` reads them too: its iOS/Android eventId needs these SDKs.
23
23
  */
24
24
  export const VERSIONS = {
25
- version: '0.11.0', // Swift (Unity is not written by this CLI)
25
+ version: '0.11.1', // Swift (Unity is not written by this CLI)
26
26
  rnVersion: '0.11.0',
27
- flutterVersion: '0.10.1',
28
- androidVersion: '0.10.0',
27
+ flutterVersion: '0.10.2',
28
+ androidVersion: '0.10.1',
29
29
  };
30
30
 
31
31
  export const PACKAGES = {
package/src/templates.mjs CHANGED
@@ -353,6 +353,9 @@ export function nextSteps(project, opts, installSrc) {
353
353
  const needsFlutterMeta = fw === 'flutter' && !project.manifest.flutterDeepLinkingOff;
354
354
  const filtersDone = hasHost && hasScheme;
355
355
  const done = filtersDone && !needsFlutterMeta;
356
+ // init adds INTERNET to a manifest it finds; without one, print it (release builds need it).
357
+ const noManifest = (fw === 'flutter' || fw === 'android') && !project.manifestPath;
358
+ const internetXml = '<!-- Inside <manifest>, above <application> -->\n<uses-permission android:name="android.permission.INTERNET" />';
356
359
  steps.push({
357
360
  title: `Add the intent filters to ${where}`,
358
361
  done,
@@ -361,7 +364,7 @@ export function nextSteps(project, opts, installSrc) {
361
364
  : filtersDone
362
365
  ? `The intent filters for ${host} and ${scheme}:// are already there. Add this line inside your main activity:`
363
366
  : 'Inside your main activity. Keep the intent filters you already have.',
364
- code: done ? undefined : [filtersDone ? null : manifestXml(opts), needsFlutterMeta ? flutterMeta : null].filter(Boolean).join('\n'),
367
+ code: done ? undefined : [noManifest ? internetXml : null, filtersDone ? null : manifestXml(opts), needsFlutterMeta ? flutterMeta : null].filter(Boolean).join('\n'),
365
368
  lang: 'xml',
366
369
  });
367
370
  if (fw !== 'android') {
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.1';
2
+ export const CLI_VERSION = '0.3.3';