@tamer4lynx/cli 0.0.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.
@@ -0,0 +1,231 @@
1
+ import { spawn } from 'child_process';
2
+ import fs from 'fs';
3
+ import http from 'http';
4
+ import os from 'os';
5
+ import path from 'path';
6
+ import { WebSocketServer } from 'ws';
7
+ import { discoverNativeExtensions } from './config';
8
+ import { resolveHostPaths, resolveIconPaths } from './hostConfig';
9
+ const DEFAULT_PORT = 3000;
10
+ function getLanIp() {
11
+ const nets = os.networkInterfaces();
12
+ for (const name of Object.keys(nets)) {
13
+ const addrs = nets[name];
14
+ if (!addrs)
15
+ continue;
16
+ for (const a of addrs) {
17
+ if (a.family === 'IPv4' && !a.internal)
18
+ return a.address;
19
+ }
20
+ }
21
+ return 'localhost';
22
+ }
23
+ async function startDevServer() {
24
+ const resolved = resolveHostPaths();
25
+ const { projectRoot, lynxProjectDir, lynxBundlePath, lynxBundleFile, config } = resolved;
26
+ const distDir = path.dirname(lynxBundlePath);
27
+ const port = config.devServer?.port ?? config.devServer?.httpPort ?? DEFAULT_PORT;
28
+ let buildProcess = null;
29
+ function runBuild() {
30
+ return new Promise((resolve, reject) => {
31
+ buildProcess = spawn('npm', ['run', 'build'], {
32
+ cwd: lynxProjectDir,
33
+ stdio: 'pipe',
34
+ });
35
+ let stderr = '';
36
+ buildProcess.stderr?.on('data', (d) => { stderr += d.toString(); });
37
+ buildProcess.on('close', (code) => {
38
+ buildProcess = null;
39
+ if (code === 0)
40
+ resolve();
41
+ else
42
+ reject(new Error(stderr || `Build exited ${code}`));
43
+ });
44
+ });
45
+ }
46
+ const projectName = path.basename(lynxProjectDir);
47
+ const basePath = `/${projectName}`;
48
+ const iconPaths = resolveIconPaths(projectRoot, config);
49
+ let iconFilePath = null;
50
+ if (iconPaths?.source && fs.statSync(iconPaths.source).isFile()) {
51
+ iconFilePath = iconPaths.source;
52
+ }
53
+ else if (iconPaths?.android) {
54
+ const androidIcon = path.join(iconPaths.android, 'mipmap-xxxhdpi', 'ic_launcher.png');
55
+ if (fs.existsSync(androidIcon))
56
+ iconFilePath = androidIcon;
57
+ }
58
+ else if (iconPaths?.ios) {
59
+ const iosIcon = path.join(iconPaths.ios, 'Icon-1024.png');
60
+ if (fs.existsSync(iosIcon))
61
+ iconFilePath = iosIcon;
62
+ }
63
+ const iconExt = iconFilePath ? path.extname(iconFilePath) || '.png' : '';
64
+ const iconMime = {
65
+ '.png': 'image/png',
66
+ '.jpg': 'image/jpeg',
67
+ '.jpeg': 'image/jpeg',
68
+ '.webp': 'image/webp',
69
+ '.ico': 'image/x-icon',
70
+ };
71
+ const httpServer = http.createServer((req, res) => {
72
+ let reqPath = (req.url || '/').split('?')[0];
73
+ if (reqPath === `${basePath}/status`) {
74
+ res.setHeader('Content-Type', 'text/plain');
75
+ res.setHeader('Access-Control-Allow-Origin', '*');
76
+ res.end('packager-status:running');
77
+ return;
78
+ }
79
+ if (reqPath === `${basePath}/meta.json`) {
80
+ const lanIp = getLanIp();
81
+ const nativeModules = discoverNativeExtensions(projectRoot);
82
+ const meta = {
83
+ name: projectName,
84
+ slug: projectName,
85
+ bundleUrl: `http://${lanIp}:${port}${basePath}/${lynxBundleFile}`,
86
+ bundleFile: lynxBundleFile,
87
+ hostUri: `http://${lanIp}:${port}${basePath}`,
88
+ debuggerHost: `${lanIp}:${port}`,
89
+ developer: { tool: 'tamer4lynx' },
90
+ packagerStatus: 'running',
91
+ nativeModules: nativeModules.map((m) => ({ packageName: m.packageName, moduleClassName: m.moduleClassName })),
92
+ };
93
+ if (iconFilePath) {
94
+ meta.icon = `http://${lanIp}:${port}${basePath}/icon${iconExt}`;
95
+ }
96
+ res.setHeader('Content-Type', 'application/json');
97
+ res.setHeader('Access-Control-Allow-Origin', '*');
98
+ res.end(JSON.stringify(meta, null, 2));
99
+ return;
100
+ }
101
+ if (iconFilePath && (reqPath === `${basePath}/icon` || reqPath === `${basePath}/icon${iconExt}`)) {
102
+ fs.readFile(iconFilePath, (err, data) => {
103
+ if (err) {
104
+ res.writeHead(404);
105
+ res.end();
106
+ return;
107
+ }
108
+ res.setHeader('Content-Type', iconMime[iconExt] ?? 'image/png');
109
+ res.setHeader('Access-Control-Allow-Origin', '*');
110
+ res.end(data);
111
+ });
112
+ return;
113
+ }
114
+ if (reqPath === '/' || reqPath === basePath || reqPath === `${basePath}/`) {
115
+ reqPath = `${basePath}/${lynxBundleFile}`;
116
+ }
117
+ else if (!reqPath.startsWith(basePath)) {
118
+ reqPath = basePath + (reqPath.startsWith('/') ? reqPath : '/' + reqPath);
119
+ }
120
+ const relPath = reqPath.replace(basePath, '').replace(/^\//, '') || lynxBundleFile;
121
+ const filePath = path.resolve(distDir, relPath);
122
+ const distResolved = path.resolve(distDir);
123
+ if (!filePath.startsWith(distResolved + path.sep) && filePath !== distResolved) {
124
+ res.writeHead(403);
125
+ res.end();
126
+ return;
127
+ }
128
+ fs.readFile(filePath, (err, data) => {
129
+ if (err) {
130
+ res.writeHead(404);
131
+ res.end('Not found');
132
+ return;
133
+ }
134
+ res.setHeader('Access-Control-Allow-Origin', '*');
135
+ res.setHeader('Content-Type', reqPath.endsWith('.bundle') ? 'application/octet-stream' : 'application/javascript');
136
+ res.end(data);
137
+ });
138
+ });
139
+ const wss = new WebSocketServer({ noServer: true });
140
+ httpServer.on('upgrade', (request, socket, head) => {
141
+ if (request.url === `${basePath}/__hmr` || request.url === '/__hmr') {
142
+ wss.handleUpgrade(request, socket, head, (ws) => wss.emit('connection', ws, request));
143
+ }
144
+ else {
145
+ socket.destroy();
146
+ }
147
+ });
148
+ wss.on('connection', (ws) => {
149
+ ws.send(JSON.stringify({ type: 'connected' }));
150
+ });
151
+ function broadcastReload() {
152
+ wss.clients.forEach((client) => {
153
+ if (client.readyState === 1)
154
+ client.send(JSON.stringify({ type: 'reload' }));
155
+ });
156
+ }
157
+ let chokidar = null;
158
+ try {
159
+ chokidar = await import('chokidar');
160
+ }
161
+ catch {
162
+ /* optional */
163
+ }
164
+ if (chokidar) {
165
+ const watchPaths = [
166
+ path.join(lynxProjectDir, 'src'),
167
+ path.join(lynxProjectDir, 'lynx.config.ts'),
168
+ path.join(lynxProjectDir, 'lynx.config.js'),
169
+ ].filter((p) => fs.existsSync(p));
170
+ if (watchPaths.length > 0) {
171
+ const watcher = chokidar.watch(watchPaths, { ignoreInitial: true });
172
+ watcher.on('change', async () => {
173
+ try {
174
+ await runBuild();
175
+ broadcastReload();
176
+ console.log('🔄 Rebuilt, clients notified');
177
+ }
178
+ catch (e) {
179
+ console.error('Build failed:', e.message);
180
+ }
181
+ });
182
+ }
183
+ }
184
+ try {
185
+ await runBuild();
186
+ }
187
+ catch (e) {
188
+ console.error('❌ Initial build failed:', e.message);
189
+ process.exit(1);
190
+ }
191
+ let stopBonjour;
192
+ httpServer.listen(port, '0.0.0.0', () => {
193
+ void import('dnssd-advertise').then(({ advertise }) => {
194
+ stopBonjour = advertise({
195
+ name: projectName,
196
+ type: 'tamer',
197
+ protocol: 'tcp',
198
+ port,
199
+ txt: {
200
+ name: projectName.slice(0, 255),
201
+ path: basePath.slice(0, 255),
202
+ },
203
+ });
204
+ }).catch(() => { });
205
+ const lanIp = getLanIp();
206
+ const devUrl = `http://${lanIp}:${port}${basePath}`;
207
+ const wsUrl = `ws://${lanIp}:${port}${basePath}/__hmr`;
208
+ console.log(`\n🚀 Tamer4Lynx dev server (${projectName})`);
209
+ console.log(` Bundle: ${devUrl}/${lynxBundleFile}`);
210
+ console.log(` Meta: ${devUrl}/meta.json`);
211
+ console.log(` HMR WS: ${wsUrl}`);
212
+ if (stopBonjour)
213
+ console.log(` mDNS: _tamer._tcp (discoverable on LAN)`);
214
+ console.log(`\n Scan QR or enter in app: ${devUrl}\n`);
215
+ void import('qrcode-terminal').then((mod) => {
216
+ const qrcode = mod.default ?? mod;
217
+ qrcode.generate(devUrl, { small: true });
218
+ }).catch(() => { });
219
+ });
220
+ const cleanup = async () => {
221
+ buildProcess?.kill();
222
+ await stopBonjour?.();
223
+ httpServer.close();
224
+ wss.close();
225
+ process.exit(0);
226
+ };
227
+ process.on('SIGINT', () => { void cleanup(); });
228
+ process.on('SIGTERM', () => { void cleanup(); });
229
+ await new Promise(() => { });
230
+ }
231
+ export default startDevServer;
@@ -0,0 +1,256 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ const TAMER_CONFIG = 'tamer.config.json';
4
+ const LYNX_CONFIG_FILES = ['lynx.config.ts', 'lynx.config.js', 'lynx.config.mjs'];
5
+ const DEFAULT_ANDROID_DIR = 'android';
6
+ const DEFAULT_IOS_DIR = 'ios';
7
+ const DEFAULT_BUNDLE_FILE = 'main.lynx.bundle';
8
+ const DEFAULT_BUNDLE_ROOT = 'dist';
9
+ function findProjectRoot(start) {
10
+ let dir = path.resolve(start);
11
+ const root = path.parse(dir).root;
12
+ while (dir !== root) {
13
+ const p = path.join(dir, TAMER_CONFIG);
14
+ if (fs.existsSync(p))
15
+ return dir;
16
+ dir = path.dirname(dir);
17
+ }
18
+ return start;
19
+ }
20
+ function loadTamerConfig(cwd) {
21
+ const p = path.join(cwd, TAMER_CONFIG);
22
+ if (!fs.existsSync(p))
23
+ return null;
24
+ try {
25
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ }
31
+ function extractDistPathRoot(configPath) {
32
+ try {
33
+ const content = fs.readFileSync(configPath, 'utf8');
34
+ const rootMatch = content.match(/distPath\s*:\s*\{\s*root\s*:\s*['"]([^'"]+)['"]/);
35
+ if (rootMatch?.[1])
36
+ return rootMatch[1];
37
+ const rootMatch2 = content.match(/root\s*:\s*['"]([^'"]+)['"]/);
38
+ if (rootMatch2?.[1])
39
+ return rootMatch2[1];
40
+ }
41
+ catch {
42
+ /* ignore */
43
+ }
44
+ return null;
45
+ }
46
+ function findLynxConfigInDir(dir) {
47
+ for (const name of LYNX_CONFIG_FILES) {
48
+ const p = path.join(dir, name);
49
+ if (fs.existsSync(p))
50
+ return p;
51
+ }
52
+ return null;
53
+ }
54
+ function hasRspeedy(pkgDir) {
55
+ const pkgJsonPath = path.join(pkgDir, 'package.json');
56
+ if (!fs.existsSync(pkgJsonPath))
57
+ return false;
58
+ try {
59
+ const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
60
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
61
+ return Object.keys(deps).some(k => k === '@lynx-js/rspeedy');
62
+ }
63
+ catch {
64
+ return false;
65
+ }
66
+ }
67
+ function discoverLynxProject(cwd, explicitPath) {
68
+ if (explicitPath) {
69
+ const resolved = path.isAbsolute(explicitPath) ? explicitPath : path.join(cwd, explicitPath);
70
+ if (fs.existsSync(resolved)) {
71
+ const lynxConfig = findLynxConfigInDir(resolved);
72
+ const bundleRoot = lynxConfig ? (extractDistPathRoot(lynxConfig) ?? DEFAULT_BUNDLE_ROOT) : DEFAULT_BUNDLE_ROOT;
73
+ return { dir: resolved, bundleRoot };
74
+ }
75
+ }
76
+ const rootPkgPath = path.join(cwd, 'package.json');
77
+ if (fs.existsSync(rootPkgPath)) {
78
+ try {
79
+ const rootPkg = JSON.parse(fs.readFileSync(rootPkgPath, 'utf8'));
80
+ const lynxConfig = findLynxConfigInDir(cwd);
81
+ if (lynxConfig || (rootPkg.dependencies?.['@lynx-js/rspeedy'] || rootPkg.devDependencies?.['@lynx-js/rspeedy'])) {
82
+ const bundleRoot = lynxConfig ? (extractDistPathRoot(lynxConfig) ?? DEFAULT_BUNDLE_ROOT) : DEFAULT_BUNDLE_ROOT;
83
+ return { dir: cwd, bundleRoot };
84
+ }
85
+ const workspaces = rootPkg.workspaces;
86
+ if (Array.isArray(workspaces)) {
87
+ for (const ws of workspaces) {
88
+ const isGlob = typeof ws === 'string' && ws.includes('*');
89
+ const dirsToCheck = isGlob
90
+ ? (() => {
91
+ const parentDir = path.join(cwd, ws.replace(/\/\*$/, ''));
92
+ if (!fs.existsSync(parentDir))
93
+ return [];
94
+ return fs.readdirSync(parentDir, { withFileTypes: true })
95
+ .filter(e => e.isDirectory())
96
+ .map(e => path.join(parentDir, e.name));
97
+ })()
98
+ : [path.join(cwd, ws)];
99
+ for (const pkgDir of dirsToCheck) {
100
+ if (!fs.existsSync(pkgDir))
101
+ continue;
102
+ const lynxConfig = findLynxConfigInDir(pkgDir);
103
+ if (lynxConfig || hasRspeedy(pkgDir)) {
104
+ const bundleRoot = lynxConfig ? (extractDistPathRoot(lynxConfig) ?? DEFAULT_BUNDLE_ROOT) : DEFAULT_BUNDLE_ROOT;
105
+ return { dir: pkgDir, bundleRoot };
106
+ }
107
+ }
108
+ }
109
+ }
110
+ }
111
+ catch {
112
+ /* ignore */
113
+ }
114
+ }
115
+ const lynxConfig = findLynxConfigInDir(cwd);
116
+ if (lynxConfig) {
117
+ const bundleRoot = extractDistPathRoot(lynxConfig) ?? DEFAULT_BUNDLE_ROOT;
118
+ return { dir: cwd, bundleRoot };
119
+ }
120
+ return null;
121
+ }
122
+ function findDevAppPackage(projectRoot) {
123
+ const candidates = [
124
+ path.join(projectRoot, 'node_modules', 'tamer-dev-app'),
125
+ path.join(projectRoot, 'packages', 'tamer-dev-app'),
126
+ ];
127
+ for (const pkg of candidates) {
128
+ if (fs.existsSync(pkg) && fs.existsSync(path.join(pkg, 'package.json'))) {
129
+ return pkg;
130
+ }
131
+ }
132
+ return null;
133
+ }
134
+ export function findDevClientPackage(projectRoot) {
135
+ const candidates = [
136
+ path.join(projectRoot, 'node_modules', 'tamer-dev-client'),
137
+ path.join(projectRoot, 'packages', 'tamer-dev-client'),
138
+ ];
139
+ for (const pkg of candidates) {
140
+ if (fs.existsSync(pkg) && fs.existsSync(path.join(pkg, 'package.json'))) {
141
+ return pkg;
142
+ }
143
+ }
144
+ return null;
145
+ }
146
+ export function resolveHostPaths(cwd = process.cwd()) {
147
+ const projectRoot = findProjectRoot(cwd);
148
+ const config = loadTamerConfig(projectRoot) ?? {};
149
+ const paths = config.paths ?? {};
150
+ const androidDirRel = paths.androidDir ?? DEFAULT_ANDROID_DIR;
151
+ const iosDirRel = paths.iosDir ?? DEFAULT_IOS_DIR;
152
+ const packageName = config.android?.packageName ?? 'com.example.app';
153
+ const explicitLynx = config.lynxProject ?? paths.lynxProject;
154
+ const discovered = discoverLynxProject(projectRoot, explicitLynx);
155
+ const lynxProjectDir = discovered?.dir ?? cwd;
156
+ const bundleRoot = paths.lynxBundleRoot ?? discovered?.bundleRoot ?? DEFAULT_BUNDLE_ROOT;
157
+ const bundleFile = paths.lynxBundleFile ?? DEFAULT_BUNDLE_FILE;
158
+ const lynxBundlePath = path.join(lynxProjectDir, bundleRoot, bundleFile);
159
+ const androidDir = path.join(projectRoot, androidDirRel);
160
+ const devMode = resolveDevMode(config);
161
+ const devAppDir = devMode === 'embedded' ? findDevAppPackage(projectRoot) : null;
162
+ const devClientPkg = findDevClientPackage(projectRoot);
163
+ const devClientBundleInClient = devClientPkg
164
+ ? path.join(devClientPkg, DEFAULT_BUNDLE_ROOT, 'dev-client.lynx.bundle')
165
+ : null;
166
+ const devClientBundleInApp = devAppDir
167
+ ? path.join(devAppDir, DEFAULT_BUNDLE_ROOT, 'dev-client.lynx.bundle')
168
+ : null;
169
+ const devClientBundlePath = devMode === 'embedded'
170
+ ? (devClientBundleInClient && fs.existsSync(devClientBundleInClient)
171
+ ? devClientBundleInClient
172
+ : devClientBundleInApp ?? undefined)
173
+ : undefined;
174
+ return {
175
+ projectRoot,
176
+ androidDir,
177
+ iosDir: path.join(projectRoot, iosDirRel),
178
+ androidAppDir: path.join(projectRoot, androidDirRel, 'app'),
179
+ androidAssetsDir: path.join(projectRoot, androidDirRel, 'app', 'src', 'main', 'assets'),
180
+ androidKotlinDir: path.join(projectRoot, androidDirRel, 'app', 'src', 'main', 'kotlin', packageName.replace(/\./g, '/')),
181
+ lynxProjectDir,
182
+ lynxBundlePath,
183
+ lynxBundleFile: bundleFile,
184
+ devMode,
185
+ devClientBundlePath,
186
+ config,
187
+ };
188
+ }
189
+ export function resolveDevMode(config) {
190
+ const explicit = config.dev?.mode;
191
+ if (explicit)
192
+ return explicit;
193
+ if (config.devServer)
194
+ return 'embedded';
195
+ return 'off';
196
+ }
197
+ export function loadHostConfig(cwd = process.cwd()) {
198
+ const cfg = loadTamerConfig(cwd);
199
+ if (!cfg)
200
+ throw new Error('tamer.config.json not found in the project root.');
201
+ return cfg;
202
+ }
203
+ export function resolveIconPaths(projectRoot, config) {
204
+ const raw = config.icon;
205
+ if (!raw)
206
+ return null;
207
+ const join = (p) => (path.isAbsolute(p) ? p : path.join(projectRoot, p));
208
+ if (typeof raw === 'string') {
209
+ const p = join(raw);
210
+ return fs.existsSync(p) ? { source: p } : null;
211
+ }
212
+ const out = {};
213
+ if (raw.source) {
214
+ const p = join(raw.source);
215
+ if (fs.existsSync(p))
216
+ out.source = p;
217
+ }
218
+ if (raw.android) {
219
+ const p = join(raw.android);
220
+ if (fs.existsSync(p))
221
+ out.android = p;
222
+ }
223
+ if (raw.ios) {
224
+ const p = join(raw.ios);
225
+ if (fs.existsSync(p))
226
+ out.ios = p;
227
+ }
228
+ return Object.keys(out).length ? out : null;
229
+ }
230
+ export function resolveDevAppPaths(repoRoot) {
231
+ const devAppDir = path.join(repoRoot, 'packages', 'tamer-dev-app');
232
+ const configPath = path.join(devAppDir, 'tamer.config.json');
233
+ if (!fs.existsSync(configPath)) {
234
+ throw new Error('packages/tamer-dev-app/tamer.config.json not found.');
235
+ }
236
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
237
+ const packageName = config.android?.packageName ?? 'com.nanofuxion.tamerdevapp';
238
+ const androidDirRel = config.paths?.androidDir ?? 'android';
239
+ const androidDir = path.join(devAppDir, androidDirRel);
240
+ const devClientDir = findDevClientPackage(repoRoot) ?? path.join(repoRoot, 'packages', 'tamer-dev-client');
241
+ const lynxBundlePath = path.join(devClientDir, DEFAULT_BUNDLE_ROOT, 'dev-client.lynx.bundle');
242
+ return {
243
+ projectRoot: devAppDir,
244
+ androidDir,
245
+ iosDir: path.join(devAppDir, 'ios'),
246
+ androidAppDir: path.join(androidDir, 'app'),
247
+ androidAssetsDir: path.join(androidDir, 'app', 'src', 'main', 'assets'),
248
+ androidKotlinDir: path.join(androidDir, 'app', 'src', 'main', 'kotlin', packageName.replace(/\./g, '/')),
249
+ lynxProjectDir: devClientDir,
250
+ lynxBundlePath,
251
+ lynxBundleFile: 'dev-client.lynx.bundle',
252
+ devMode: 'embedded',
253
+ devClientBundlePath: undefined,
254
+ config,
255
+ };
256
+ }
@@ -0,0 +1,65 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import readline from "readline";
4
+ const rl = readline.createInterface({
5
+ input: process.stdin,
6
+ output: process.stdout
7
+ });
8
+ function ask(question) {
9
+ return new Promise(resolve => {
10
+ rl.question(question, answer => resolve(answer.trim()));
11
+ });
12
+ }
13
+ async function init() {
14
+ process.removeAllListeners('warning');
15
+ console.log("Tamer4Lynx Init: Let's set up your tamer.config.json\n");
16
+ // Android
17
+ const androidAppName = await ask("Android app name: ");
18
+ const androidPackageName = await ask("Android package name (e.g. com.example.app): ");
19
+ let androidSdk = await ask("Android SDK path (e.g. ~/Library/Android/sdk or $ANDROID_HOME): ");
20
+ // Normalize SDK path if starts with $ and is all caps
21
+ if (androidSdk.startsWith("$") && /^[A-Z0-9_]+$/.test(androidSdk.slice(1))) {
22
+ const envVar = androidSdk.slice(1);
23
+ const envValue = process.env[envVar];
24
+ if (envValue) {
25
+ androidSdk = envValue;
26
+ console.log(`Resolved ${androidSdk} from $${envVar}`);
27
+ }
28
+ else {
29
+ console.warn(`Environment variable $${envVar} not found. SDK path will be left as-is.`);
30
+ }
31
+ }
32
+ // Ask if user wants to use same name/bundle id for iOS as Android
33
+ const useSame = await ask("Use same name and bundle ID for iOS as Android? (y/N): ");
34
+ let iosAppName;
35
+ let iosBundleId;
36
+ if (/^y(es)?$/i.test(useSame)) {
37
+ iosAppName = androidAppName;
38
+ iosBundleId = androidPackageName;
39
+ }
40
+ else {
41
+ iosAppName = await ask("iOS app name: ");
42
+ iosBundleId = await ask("iOS bundle ID (e.g. com.example.app): ");
43
+ }
44
+ // Ask for lynxProject path
45
+ const lynxProject = await ask("Lynx project path (relative to project root, e.g. packages/example) [optional]: ");
46
+ const config = {
47
+ android: {
48
+ appName: androidAppName || undefined,
49
+ packageName: androidPackageName || undefined,
50
+ sdk: androidSdk || undefined
51
+ },
52
+ ios: {
53
+ appName: iosAppName || undefined,
54
+ bundleId: iosBundleId || undefined
55
+ },
56
+ paths: { androidDir: "android", iosDir: "ios" }
57
+ };
58
+ if (lynxProject)
59
+ config.lynxProject = lynxProject;
60
+ const configPath = path.join(process.cwd(), "tamer.config.json");
61
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
62
+ console.log(`\n✅ Generated tamer.config.json at ${configPath}`);
63
+ rl.close();
64
+ }
65
+ export default init;
@@ -0,0 +1,39 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import alink from "../android/autolink";
4
+ import ilink from "../ios/autolink";
5
+ import { resolveHostPaths } from './hostConfig';
6
+ (() => {
7
+ const configPath = path.join(process.cwd(), 'tamer.config.json');
8
+ let config = {};
9
+ if (fs.existsSync(configPath)) {
10
+ config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
11
+ }
12
+ else {
13
+ console.warn('⚠️ tamer.config.json not found. Skipping autolinking.');
14
+ return;
15
+ }
16
+ if (config.autolink) {
17
+ try {
18
+ const { androidDir, iosDir } = resolveHostPaths();
19
+ if (fs.existsSync(androidDir))
20
+ alink();
21
+ if (process.platform === 'darwin' && fs.existsSync(iosDir))
22
+ ilink();
23
+ }
24
+ catch {
25
+ const androidRoot = path.join(process.cwd(), 'android');
26
+ if (fs.existsSync(androidRoot))
27
+ alink();
28
+ if (process.platform === 'darwin') {
29
+ const iosRoot = path.join(process.cwd(), 'ios');
30
+ if (fs.existsSync(iosRoot))
31
+ ilink();
32
+ }
33
+ }
34
+ console.log('Autolinking complete.');
35
+ }
36
+ else {
37
+ console.log('Autolinking not enabled in tamer.config.json.');
38
+ }
39
+ })();
@@ -0,0 +1,5 @@
1
+ import devServer from './devServer';
2
+ async function start() {
3
+ await devServer();
4
+ }
5
+ export default start;
@@ -0,0 +1,47 @@
1
+ export function getDevServerPrefs(vars) {
2
+ return `package ${vars.packageName}
3
+
4
+ import android.content.Context
5
+ import android.content.SharedPreferences
6
+ import org.json.JSONArray
7
+
8
+ object DevServerPrefs {
9
+ private const val PREFS = "tamer_dev_server"
10
+ private const val KEY_URL = "dev_server_url"
11
+ private const val KEY_RECENT = "dev_server_recent"
12
+
13
+ fun getUrl(context: Context): String? {
14
+ return prefs(context).getString(KEY_URL, null)
15
+ }
16
+
17
+ fun setUrl(context: Context, url: String) {
18
+ prefs(context).edit().putString(KEY_URL, url).apply()
19
+ addRecent(context, url)
20
+ }
21
+
22
+ fun getRecentUrls(context: Context): List<String> {
23
+ val json = prefs(context).getString(KEY_RECENT, "[]") ?: "[]"
24
+ return try {
25
+ val arr = JSONArray(json)
26
+ (0 until arr.length()).map { arr.getString(it) }.distinct().take(10)
27
+ } catch (_: Exception) { emptyList() }
28
+ }
29
+
30
+ fun addRecent(context: Context, url: String) {
31
+ val current = getRecentUrls(context).filter { it != url }
32
+ val updated = listOf(url) + current
33
+ prefs(context).edit()
34
+ .putString(KEY_RECENT, JSONArray(updated.take(10)).toString())
35
+ .apply()
36
+ }
37
+
38
+ fun clear(context: Context) {
39
+ prefs(context).edit().clear().apply()
40
+ }
41
+
42
+ private fun prefs(context: Context): SharedPreferences {
43
+ return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
44
+ }
45
+ }
46
+ `;
47
+ }