@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 +2 -2
- package/package.json +1 -1
- package/src/cli.mjs +4 -2
- package/src/init.mjs +24 -4
- package/src/install.mjs +8 -5
- package/src/templates.mjs +15 -6
- package/src/validate.mjs +23 -5
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
|
|
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
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,
|
|
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
|
-
|
|
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(
|
|
68
|
-
|
|
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
|
-
/**
|
|
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.
|
|
22
|
-
rnVersion: '0.9.
|
|
23
|
-
flutterVersion: '0.9.
|
|
24
|
-
androidVersion: '0.8.
|
|
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
|
|
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.' :
|
|
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
|
-
|
|
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 →
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
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". */
|