@emeryld/manager 1.3.0 → 1.4.0
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 +96 -0
- package/dist/create-package/cli-args.js +78 -0
- package/dist/create-package/prompts.js +138 -0
- package/dist/create-package/shared/configs.js +309 -0
- package/dist/create-package/shared/constants.js +5 -0
- package/dist/create-package/shared/fs-utils.js +69 -0
- package/dist/create-package/tasks.js +89 -0
- package/dist/create-package/types.js +1 -0
- package/dist/create-package/variant-info.js +67 -0
- package/dist/create-package/variants/client/expo-react-native/lib-files.js +168 -0
- package/dist/create-package/variants/client/expo-react-native/package-files.js +94 -0
- package/dist/create-package/variants/client/expo-react-native/scaffold.js +59 -0
- package/dist/create-package/variants/client/expo-react-native/ui-files.js +215 -0
- package/dist/create-package/variants/client/vite-react/health-page.js +251 -0
- package/dist/create-package/variants/client/vite-react/lib-files.js +176 -0
- package/dist/create-package/variants/client/vite-react/package-files.js +79 -0
- package/dist/create-package/variants/client/vite-react/scaffold.js +68 -0
- package/dist/create-package/variants/client/vite-react/ui-files.js +154 -0
- package/dist/create-package/variants/fullstack/files.js +129 -0
- package/dist/create-package/variants/fullstack/index.js +86 -0
- package/dist/create-package/variants/fullstack/utils.js +241 -0
- package/dist/llm-pack.js +2 -0
- package/dist/robot/cli/prompts.js +84 -27
- package/dist/robot/cli/settings.js +131 -56
- package/dist/robot/config.js +123 -50
- package/dist/robot/coordinator.js +10 -105
- package/dist/robot/extractors/classes.js +14 -13
- package/dist/robot/extractors/components.js +17 -10
- package/dist/robot/extractors/constants.js +9 -6
- package/dist/robot/extractors/functions.js +11 -8
- package/dist/robot/extractors/shared.js +6 -1
- package/dist/robot/extractors/types.js +5 -8
- package/dist/robot/llm-pack.js +1226 -0
- package/dist/robot/pack/builder.js +374 -0
- package/dist/robot/pack/cli.js +65 -0
- package/dist/robot/pack/exemplars.js +573 -0
- package/dist/robot/pack/globs.js +119 -0
- package/dist/robot/pack/selection.js +44 -0
- package/dist/robot/pack/symbols.js +309 -0
- package/dist/robot/pack/type-registry.js +285 -0
- package/dist/robot/pack/types.js +48 -0
- package/dist/robot/pack/utils.js +36 -0
- package/dist/robot/serializer.js +97 -0
- package/dist/robot/v2/cli.js +86 -0
- package/dist/robot/v2/globs.js +103 -0
- package/dist/robot/v2/parser/bundles.js +55 -0
- package/dist/robot/v2/parser/candidates.js +63 -0
- package/dist/robot/v2/parser/exemplars.js +114 -0
- package/dist/robot/v2/parser/exports.js +57 -0
- package/dist/robot/v2/parser/symbols.js +179 -0
- package/dist/robot/v2/parser.js +114 -0
- package/dist/robot/v2/types.js +42 -0
- package/dist/utils/export.js +39 -18
- package/package.json +2 -1
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { access, mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { promptYesNoAll } from '../../prompts.js';
|
|
4
|
+
import { baseEslintConfig, basePrettierConfig, HUSKY_PRE_COMMIT, pathExists, PRETTIER_IGNORE, resolveRootTsconfig, toPosixPath, vscodeSettings, } from './configs.js';
|
|
5
|
+
import { workspaceRoot } from './constants.js';
|
|
6
|
+
export async function writeFileIfMissing(baseDir, relative, contents) {
|
|
7
|
+
const fullPath = path.join(baseDir, relative);
|
|
8
|
+
await mkdir(path.dirname(fullPath), { recursive: true });
|
|
9
|
+
try {
|
|
10
|
+
await access(fullPath);
|
|
11
|
+
const rel = path.relative(workspaceRoot, fullPath);
|
|
12
|
+
console.log(` skipped ${rel} (already exists)`);
|
|
13
|
+
return 'skipped';
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
if (error &&
|
|
17
|
+
typeof error === 'object' &&
|
|
18
|
+
error.code !== 'ENOENT') {
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
await writeFile(fullPath, contents, 'utf8');
|
|
23
|
+
const rel = path.relative(workspaceRoot, fullPath);
|
|
24
|
+
console.log(` created ${rel}`);
|
|
25
|
+
return 'created';
|
|
26
|
+
}
|
|
27
|
+
export async function writeFileWithPrompt(baseDir, relative, contents) {
|
|
28
|
+
const fullPath = path.join(baseDir, relative);
|
|
29
|
+
await mkdir(path.dirname(fullPath), { recursive: true });
|
|
30
|
+
const rel = path.relative(workspaceRoot, fullPath);
|
|
31
|
+
const exists = await pathExists(fullPath);
|
|
32
|
+
if (exists) {
|
|
33
|
+
const answer = await promptYesNoAll(`Overwrite existing ${rel}?`);
|
|
34
|
+
if (answer !== 'yes') {
|
|
35
|
+
console.log(` kept existing ${rel}`);
|
|
36
|
+
return 'skipped';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
await writeFile(fullPath, contents, 'utf8');
|
|
40
|
+
console.log(` ${exists ? 'updated' : 'created'} ${rel}`);
|
|
41
|
+
return exists ? 'updated' : 'created';
|
|
42
|
+
}
|
|
43
|
+
export function workspaceToolingFiles(tsconfigPath) {
|
|
44
|
+
return {
|
|
45
|
+
'eslint.config.js': baseEslintConfig(tsconfigPath),
|
|
46
|
+
'prettier.config.js': basePrettierConfig(),
|
|
47
|
+
'.prettierignore': `${PRETTIER_IGNORE}\n`,
|
|
48
|
+
'.vscode/settings.json': vscodeSettings(),
|
|
49
|
+
'.husky/pre-commit': HUSKY_PRE_COMMIT,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
export async function ensureWorkspaceToolingFiles(baseDir, options) {
|
|
53
|
+
const defaultTsconfig = path.join(baseDir, 'tsconfig.json');
|
|
54
|
+
const resolvedRootConfig = options?.tsconfigPath
|
|
55
|
+
? path.resolve(baseDir, options.tsconfigPath)
|
|
56
|
+
: await resolveRootTsconfig(baseDir);
|
|
57
|
+
const relativeTsconfig = path.relative(baseDir, resolvedRootConfig ?? defaultTsconfig);
|
|
58
|
+
const normalized = toPosixPath(relativeTsconfig || './tsconfig.json');
|
|
59
|
+
const tsconfigPath = normalized.startsWith('.') ? normalized : `./${normalized}`;
|
|
60
|
+
const files = workspaceToolingFiles(tsconfigPath);
|
|
61
|
+
for (const [relative, contents] of Object.entries(files)) {
|
|
62
|
+
await writeFileWithPrompt(baseDir, relative, contents);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export async function writeFilesIfMissing(baseDir, files) {
|
|
66
|
+
for (const [relative, contents] of Object.entries(files)) {
|
|
67
|
+
await writeFileIfMissing(baseDir, relative, contents);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { mkdir, readdir, readFile, rm, stat } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { colors, logGlobal } from '../utils/log.js';
|
|
4
|
+
import { run } from '../utils/run.js';
|
|
5
|
+
import { workspaceRoot } from './shared.js';
|
|
6
|
+
export async function ensureTargetDir(targetDir, options) {
|
|
7
|
+
const resolvedTarget = path.resolve(targetDir);
|
|
8
|
+
const shouldReset = options?.reset ?? false;
|
|
9
|
+
if (shouldReset && resolvedTarget === workspaceRoot) {
|
|
10
|
+
throw new Error('Refusing to reset the workspace root directory.');
|
|
11
|
+
}
|
|
12
|
+
try {
|
|
13
|
+
const stats = await stat(targetDir);
|
|
14
|
+
if (!stats.isDirectory()) {
|
|
15
|
+
throw new Error(`Target "${targetDir}" exists and is not a directory.`);
|
|
16
|
+
}
|
|
17
|
+
if (shouldReset) {
|
|
18
|
+
logGlobal(`Resetting existing target ${path.relative(workspaceRoot, resolvedTarget) || '.'}…`, colors.yellow);
|
|
19
|
+
await rm(resolvedTarget, { recursive: true, force: true });
|
|
20
|
+
await mkdir(resolvedTarget, { recursive: true });
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const entries = await readdir(targetDir);
|
|
24
|
+
if (entries.length > 0) {
|
|
25
|
+
logGlobal(`Target ${path.relative(workspaceRoot, targetDir)} is not empty; existing files will be preserved.`, colors.yellow);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
if (error &&
|
|
30
|
+
typeof error === 'object' &&
|
|
31
|
+
error.code === 'ENOENT') {
|
|
32
|
+
await mkdir(resolvedTarget, { recursive: true });
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export async function postCreateTasks(targetDir, options) {
|
|
39
|
+
if (options?.skipInstall) {
|
|
40
|
+
logGlobal('Skipping pnpm install (flag).', colors.dim);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
logGlobal('Running pnpm install…', colors.cyan);
|
|
45
|
+
await run('pnpm', ['install'], { cwd: workspaceRoot });
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
logGlobal(`pnpm install failed: ${error instanceof Error ? error.message : String(error)}`, colors.yellow);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (options?.skipBuild) {
|
|
52
|
+
logGlobal('Skipping build (flag).', colors.dim);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
if (options?.pkgName) {
|
|
57
|
+
logGlobal(`Building workspace deps for ${options.pkgName}…`, colors.cyan);
|
|
58
|
+
await run('pnpm', ['-r', '--filter', `${options.pkgName}...`, 'build'], {
|
|
59
|
+
cwd: workspaceRoot,
|
|
60
|
+
});
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
logGlobal(`Filtered workspace build failed; will try full workspace build: ${error instanceof Error ? error.message : String(error)}`, colors.yellow);
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
logGlobal('Building full workspace…', colors.cyan);
|
|
69
|
+
await run('pnpm', ['-r', 'build'], { cwd: workspaceRoot });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
logGlobal(`Full workspace build failed; falling back to building only the new package: ${error instanceof Error ? error.message : String(error)}`, colors.yellow);
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const pkgJsonPath = path.join(targetDir, 'package.json');
|
|
77
|
+
const pkgRaw = await readFile(pkgJsonPath, 'utf8');
|
|
78
|
+
const pkg = JSON.parse(pkgRaw);
|
|
79
|
+
if (pkg.scripts?.build) {
|
|
80
|
+
logGlobal('Running pnpm run build for the new package…', colors.cyan);
|
|
81
|
+
await run('pnpm', ['-C', targetDir, 'run', 'build'], {
|
|
82
|
+
cwd: workspaceRoot,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
logGlobal(`Skipping build (could not read package.json): ${error instanceof Error ? error.message : String(error)}`, colors.yellow);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { colors, logGlobal } from '../utils/log.js';
|
|
3
|
+
import { SCRIPT_DESCRIPTIONS } from './shared.js';
|
|
4
|
+
import { clientVariant } from './variants/client.js';
|
|
5
|
+
import { contractVariant } from './variants/contract.js';
|
|
6
|
+
import { dockerVariant } from './variants/docker.js';
|
|
7
|
+
import { emptyVariant } from './variants/empty.js';
|
|
8
|
+
import { fullstackVariant } from './variants/fullstack.js';
|
|
9
|
+
import { serverVariant } from './variants/server.js';
|
|
10
|
+
export const VARIANTS = [
|
|
11
|
+
contractVariant,
|
|
12
|
+
serverVariant,
|
|
13
|
+
clientVariant,
|
|
14
|
+
emptyVariant,
|
|
15
|
+
dockerVariant,
|
|
16
|
+
fullstackVariant,
|
|
17
|
+
];
|
|
18
|
+
export const VARIANT_LOOKUP = new Map(VARIANTS.map((variant) => [variant.id, variant]));
|
|
19
|
+
export function derivePackageName(targetDir) {
|
|
20
|
+
const base = path.basename(targetDir) || 'rrr-package';
|
|
21
|
+
return base;
|
|
22
|
+
}
|
|
23
|
+
export function resolveVariant(key) {
|
|
24
|
+
if (!key)
|
|
25
|
+
return undefined;
|
|
26
|
+
const normalized = key.toLowerCase();
|
|
27
|
+
return (VARIANT_LOOKUP.get(normalized) ??
|
|
28
|
+
VARIANTS.find((variant) => {
|
|
29
|
+
const label = variant.label.toLowerCase();
|
|
30
|
+
const id = variant.id.toLowerCase();
|
|
31
|
+
return (id === normalized ||
|
|
32
|
+
id.replace(/^rrr-/, '') === normalized ||
|
|
33
|
+
label === normalized ||
|
|
34
|
+
label.includes(normalized));
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
export function printVariantList() {
|
|
38
|
+
logGlobal('Available templates', colors.magenta);
|
|
39
|
+
VARIANTS.forEach((variant) => {
|
|
40
|
+
const summary = variant.summary ? ` ${colors.dim(`– ${variant.summary}`)}` : '';
|
|
41
|
+
console.log(`- ${colors.green(variant.label)} ${colors.dim(`(${variant.id})`)} → ${colors.cyan(variant.defaultDir)}${summary}`);
|
|
42
|
+
});
|
|
43
|
+
console.log(colors.dim('Use "--describe <variant>" for details or "--variant <id> --dir <path>" to scaffold without prompts.'));
|
|
44
|
+
}
|
|
45
|
+
export function printVariantDetails(variant) {
|
|
46
|
+
logGlobal(`${variant.label} (${variant.id})`, colors.green);
|
|
47
|
+
console.log(` default dir: ${colors.cyan(variant.defaultDir)}`);
|
|
48
|
+
if (variant.summary) {
|
|
49
|
+
console.log(` ${variant.summary}`);
|
|
50
|
+
}
|
|
51
|
+
if (variant.keyFiles?.length) {
|
|
52
|
+
console.log(' key files:');
|
|
53
|
+
variant.keyFiles.forEach((file) => console.log(` - ${file}`));
|
|
54
|
+
}
|
|
55
|
+
if (variant.scripts?.length) {
|
|
56
|
+
console.log(' scripts:');
|
|
57
|
+
variant.scripts.forEach((script) => {
|
|
58
|
+
const desc = SCRIPT_DESCRIPTIONS[script];
|
|
59
|
+
const detail = desc ? colors.dim(` – ${desc}`) : '';
|
|
60
|
+
console.log(` - ${script}${detail}`);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
if (variant.notes?.length) {
|
|
64
|
+
console.log(' notes:');
|
|
65
|
+
variant.notes.forEach((note) => console.log(` - ${note}`));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
export function nativeQueryClient(contractImport) {
|
|
2
|
+
if (contractImport) {
|
|
3
|
+
return `import Constants from 'expo-constants'
|
|
4
|
+
import { QueryClient } from '@tanstack/react-query'
|
|
5
|
+
import { createRouteClient } from '@emeryld/rrroutes-client'
|
|
6
|
+
import { registry } from '${contractImport}'
|
|
7
|
+
|
|
8
|
+
const { apiUrl } = (Constants.expoConfig?.extra ?? {}) as Record<string, string>
|
|
9
|
+
|
|
10
|
+
export const queryClient = new QueryClient()
|
|
11
|
+
|
|
12
|
+
export const routeClient = createRouteClient({
|
|
13
|
+
baseUrl: apiUrl ?? 'http://localhost:4000',
|
|
14
|
+
queryClient,
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
export const healthGet = routeClient.build(registry.byKey['GET /api/health'])
|
|
18
|
+
export const healthPost = routeClient.build(registry.byKey['POST /api/health'])
|
|
19
|
+
export const hasContract = true as const
|
|
20
|
+
`;
|
|
21
|
+
}
|
|
22
|
+
return `import Constants from 'expo-constants'
|
|
23
|
+
import { QueryClient } from '@tanstack/react-query'
|
|
24
|
+
import { createRouteClient } from '@emeryld/rrroutes-client'
|
|
25
|
+
|
|
26
|
+
const { apiUrl } = (Constants.expoConfig?.extra ?? {}) as Record<string, string>
|
|
27
|
+
|
|
28
|
+
export const queryClient = new QueryClient()
|
|
29
|
+
|
|
30
|
+
export const routeClient = createRouteClient({
|
|
31
|
+
baseUrl: apiUrl ?? 'http://localhost:4000',
|
|
32
|
+
queryClient,
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
type RouteEndpoint = ReturnType<typeof routeClient.build>
|
|
36
|
+
|
|
37
|
+
function placeholderUseEndpoint() {
|
|
38
|
+
const error = new Error('Add your contract import to src/queryClient.ts')
|
|
39
|
+
return {
|
|
40
|
+
data: undefined,
|
|
41
|
+
error,
|
|
42
|
+
refetch: () => undefined,
|
|
43
|
+
mutateAsync: async () => Promise.reject(error),
|
|
44
|
+
isPending: false,
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const placeholderEndpoint = {
|
|
49
|
+
useEndpoint: placeholderUseEndpoint,
|
|
50
|
+
} as unknown as RouteEndpoint
|
|
51
|
+
|
|
52
|
+
export const healthGet: RouteEndpoint = placeholderEndpoint
|
|
53
|
+
export const healthPost: RouteEndpoint = placeholderEndpoint
|
|
54
|
+
export const hasContract = false as const
|
|
55
|
+
`;
|
|
56
|
+
}
|
|
57
|
+
export function nativeSocket(contractImport) {
|
|
58
|
+
if (contractImport) {
|
|
59
|
+
return `import React from 'react'
|
|
60
|
+
import Constants from 'expo-constants'
|
|
61
|
+
import { io, type Socket } from 'socket.io-client'
|
|
62
|
+
import {
|
|
63
|
+
buildSocketProvider,
|
|
64
|
+
type SocketClientOptions,
|
|
65
|
+
} from '@emeryld/rrroutes-client'
|
|
66
|
+
import { socketConfig, socketEvents } from '${contractImport}'
|
|
67
|
+
|
|
68
|
+
const extras = (Constants.expoConfig?.extra ?? {}) as Record<string, string>
|
|
69
|
+
const socketUrl = extras.socketUrl ?? 'http://localhost:4000'
|
|
70
|
+
const socketPath = extras.socketPath ?? '/socket.io'
|
|
71
|
+
|
|
72
|
+
const sysEvents: SocketClientOptions<
|
|
73
|
+
typeof socketEvents,
|
|
74
|
+
typeof socketConfig
|
|
75
|
+
>['sys'] = {
|
|
76
|
+
'sys:connect': async ({ socket }) => {
|
|
77
|
+
console.log('socket connected', socket.id)
|
|
78
|
+
},
|
|
79
|
+
'sys:disconnect': async ({ reason }) => console.log('disconnected', reason),
|
|
80
|
+
'sys:reconnect': async ({ attempt, socket }) =>
|
|
81
|
+
console.log('reconnect', attempt, socket?.id),
|
|
82
|
+
'sys:connect_error': async ({ error }) =>
|
|
83
|
+
console.warn('socket connect error', error),
|
|
84
|
+
'sys:ping': () => ({
|
|
85
|
+
note: 'client-heartbeat',
|
|
86
|
+
sentAt: new Date().toISOString(),
|
|
87
|
+
}),
|
|
88
|
+
'sys:pong': async ({ payload }) => console.log('pong', payload),
|
|
89
|
+
'sys:room_join': async ({ rooms }) => {
|
|
90
|
+
console.log('join rooms', rooms)
|
|
91
|
+
return true
|
|
92
|
+
},
|
|
93
|
+
'sys:room_leave': async ({ rooms }) => {
|
|
94
|
+
console.log('leave rooms', rooms)
|
|
95
|
+
return true
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const baseOptions: Omit<
|
|
100
|
+
SocketClientOptions<typeof socketEvents, typeof socketConfig>,
|
|
101
|
+
'socket'
|
|
102
|
+
> = {
|
|
103
|
+
config: socketConfig,
|
|
104
|
+
sys: sysEvents,
|
|
105
|
+
heartbeat: { intervalMs: 15_000, timeoutMs: 7_500 },
|
|
106
|
+
debug: { connection: true },
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const { SocketProvider, useSocketClient, useSocketConnection } =
|
|
110
|
+
buildSocketProvider({
|
|
111
|
+
events: socketEvents,
|
|
112
|
+
options: baseOptions,
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
function getSocket(): Promise<Socket> {
|
|
116
|
+
return Promise.resolve(
|
|
117
|
+
io(socketUrl, {
|
|
118
|
+
path: socketPath,
|
|
119
|
+
transports: ['websocket'],
|
|
120
|
+
}),
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export const roomMeta = { room: 'health' }
|
|
125
|
+
export const socketReady = true as const
|
|
126
|
+
|
|
127
|
+
export function AppSocketProvider(props: React.PropsWithChildren) {
|
|
128
|
+
return (
|
|
129
|
+
<SocketProvider
|
|
130
|
+
getSocket={getSocket}
|
|
131
|
+
destroyLeaveMeta={roomMeta}
|
|
132
|
+
fallback={<></>}
|
|
133
|
+
>
|
|
134
|
+
{props.children}
|
|
135
|
+
</SocketProvider>
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export { useSocketClient, useSocketConnection }
|
|
140
|
+
`;
|
|
141
|
+
}
|
|
142
|
+
return `import React from 'react'
|
|
143
|
+
|
|
144
|
+
export const roomMeta = { room: 'health' }
|
|
145
|
+
export const socketReady = false as const
|
|
146
|
+
|
|
147
|
+
const stubSocket = {
|
|
148
|
+
connect: () =>
|
|
149
|
+
console.info('Socket disabled; add your contract import in src/socket.tsx.'),
|
|
150
|
+
disconnect: () => undefined,
|
|
151
|
+
emit: () => undefined,
|
|
152
|
+
joinRooms: () => undefined,
|
|
153
|
+
leaveRooms: () => undefined,
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function AppSocketProvider(props: React.PropsWithChildren) {
|
|
157
|
+
return <>{props.children}</>
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function useSocketClient() {
|
|
161
|
+
return stubSocket
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function useSocketConnection() {
|
|
165
|
+
// no-op when sockets are not configured
|
|
166
|
+
}
|
|
167
|
+
`;
|
|
168
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { basePackageJson, baseScripts } from '../../../shared.js';
|
|
2
|
+
export const EXPO_CLIENT_SCRIPTS = [
|
|
3
|
+
'dev',
|
|
4
|
+
'build',
|
|
5
|
+
'typecheck',
|
|
6
|
+
'lint',
|
|
7
|
+
'lint:fix',
|
|
8
|
+
'format',
|
|
9
|
+
'format:check',
|
|
10
|
+
'clean',
|
|
11
|
+
'start',
|
|
12
|
+
'android',
|
|
13
|
+
'ios',
|
|
14
|
+
'web',
|
|
15
|
+
'test',
|
|
16
|
+
];
|
|
17
|
+
function slugify(name) {
|
|
18
|
+
const cleaned = name.replace(/^@/, '').replace(/\//g, '-');
|
|
19
|
+
const slug = cleaned
|
|
20
|
+
.split(/[^a-zA-Z0-9]+/)
|
|
21
|
+
.filter(Boolean)
|
|
22
|
+
.join('-')
|
|
23
|
+
.toLowerCase();
|
|
24
|
+
return slug || 'rrr-client';
|
|
25
|
+
}
|
|
26
|
+
export function nativePackageJson(name, contractName, includePrepare) {
|
|
27
|
+
const dependencies = {
|
|
28
|
+
'@emeryld/rrroutes-client': '^2.5.3',
|
|
29
|
+
'@tanstack/react-query': '^5.90.12',
|
|
30
|
+
expo: '~52.0.8',
|
|
31
|
+
'expo-constants': '~16.0.2',
|
|
32
|
+
'expo-status-bar': '~2.0.1',
|
|
33
|
+
react: '18.3.1',
|
|
34
|
+
'react-native': '0.76.6',
|
|
35
|
+
'react-native-safe-area-context': '4.12.0',
|
|
36
|
+
'react-native-screens': '4.4.0',
|
|
37
|
+
'socket.io-client': '^4.8.3',
|
|
38
|
+
zod: '^4.2.1',
|
|
39
|
+
};
|
|
40
|
+
if (contractName)
|
|
41
|
+
dependencies[contractName] = 'workspace:*';
|
|
42
|
+
const devDependencies = {
|
|
43
|
+
'@babel/core': '^7.25.2',
|
|
44
|
+
'@types/react': '~18.2.79',
|
|
45
|
+
'@types/react-native': '~0.73.0',
|
|
46
|
+
'babel-preset-expo': '^11.0.0',
|
|
47
|
+
typescript: '^5.9.3',
|
|
48
|
+
};
|
|
49
|
+
return basePackageJson({
|
|
50
|
+
name,
|
|
51
|
+
main: 'expo/AppEntry',
|
|
52
|
+
useDefaults: false,
|
|
53
|
+
scripts: baseScripts('expo start', {
|
|
54
|
+
start: 'expo start',
|
|
55
|
+
android: 'expo start --android',
|
|
56
|
+
ios: 'expo start --ios',
|
|
57
|
+
web: 'expo start --web',
|
|
58
|
+
build: 'tsc -p tsconfig.json --noEmit',
|
|
59
|
+
test: 'echo "add tests"',
|
|
60
|
+
}, { includePrepare }),
|
|
61
|
+
dependencies,
|
|
62
|
+
devDependencies,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
export function nativeAppJson(pkgName) {
|
|
66
|
+
const baseSlug = slugify(pkgName);
|
|
67
|
+
const slug = `${baseSlug}-client`;
|
|
68
|
+
return `${JSON.stringify({
|
|
69
|
+
expo: {
|
|
70
|
+
name: slug,
|
|
71
|
+
slug,
|
|
72
|
+
version: '0.1.0',
|
|
73
|
+
orientation: 'portrait',
|
|
74
|
+
scheme: baseSlug,
|
|
75
|
+
platforms: ['ios', 'android', 'web'],
|
|
76
|
+
userInterfaceStyle: 'light',
|
|
77
|
+
extra: {
|
|
78
|
+
apiUrl: 'http://localhost:4000',
|
|
79
|
+
socketUrl: 'http://localhost:4000',
|
|
80
|
+
socketPath: '/socket.io',
|
|
81
|
+
},
|
|
82
|
+
experiments: {
|
|
83
|
+
typedRoutes: false,
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
}, null, 2)}\n`;
|
|
87
|
+
}
|
|
88
|
+
export const NATIVE_BABEL = `module.exports = function (api) {
|
|
89
|
+
api.cache(true)
|
|
90
|
+
return {
|
|
91
|
+
presets: ['babel-preset-expo'],
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
`;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { ModuleResolutionKind } from 'typescript';
|
|
2
|
+
import { basePackageFiles, buildReadme, isWorkspaceRoot, packageTsConfig, writeFilesIfMissing, } from '../../../shared.js';
|
|
3
|
+
import { EXPO_CLIENT_SCRIPTS, NATIVE_BABEL, nativeAppJson, nativePackageJson, } from './package-files.js';
|
|
4
|
+
import { nativeAppTsx, nativeHealthScreen } from './ui-files.js';
|
|
5
|
+
import { nativeQueryClient, nativeSocket } from './lib-files.js';
|
|
6
|
+
export async function scaffoldExpoReactNativeClient(ctx) {
|
|
7
|
+
const includePrepare = isWorkspaceRoot(ctx.targetDir);
|
|
8
|
+
const tsconfig = await packageTsConfig(ctx.targetDir, {
|
|
9
|
+
include: ['App.tsx', 'src/**/*.ts', 'src/**/*.tsx'],
|
|
10
|
+
jsx: 'react-native',
|
|
11
|
+
types: ['react', 'react-native'],
|
|
12
|
+
target: 'ES2022',
|
|
13
|
+
module: 'ESNext',
|
|
14
|
+
ModuleResolutionKind: ModuleResolutionKind.Bundler,
|
|
15
|
+
rootDir: '.',
|
|
16
|
+
});
|
|
17
|
+
const files = {
|
|
18
|
+
'package.json': nativePackageJson(ctx.pkgName, ctx.contractName, includePrepare),
|
|
19
|
+
'tsconfig.json': tsconfig,
|
|
20
|
+
'app.json': nativeAppJson(ctx.pkgName),
|
|
21
|
+
'babel.config.js': NATIVE_BABEL,
|
|
22
|
+
'App.tsx': nativeAppTsx(),
|
|
23
|
+
'src/queryClient.ts': nativeQueryClient(ctx.contractName),
|
|
24
|
+
'src/socket.tsx': nativeSocket(ctx.contractName),
|
|
25
|
+
'src/screens/HealthScreen.tsx': nativeHealthScreen(),
|
|
26
|
+
'README.md': buildReadme({
|
|
27
|
+
name: ctx.pkgName,
|
|
28
|
+
description: 'Expo + React Native RRRoutes client scaffold.',
|
|
29
|
+
scripts: [...EXPO_CLIENT_SCRIPTS],
|
|
30
|
+
sections: [
|
|
31
|
+
{
|
|
32
|
+
title: 'Getting Started',
|
|
33
|
+
lines: [
|
|
34
|
+
'- Install deps: `npm install` (or `pnpm install`).',
|
|
35
|
+
'- Start Expo dev server: `npm run dev` (or `npm start`).',
|
|
36
|
+
'- Launch on device/simulator: `npm run android`, `npm run ios`, or `npm run web`.',
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
title: 'Contract wiring',
|
|
41
|
+
lines: [
|
|
42
|
+
ctx.contractName
|
|
43
|
+
? `- Contract wired to ${ctx.contractName}; adjust the import in \`src/queryClient.ts\` if needed.`
|
|
44
|
+
: '- Update `src/queryClient.ts` and `src/socket.tsx` with your contract import to enable HTTP + socket helpers.',
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
title: 'Environment',
|
|
49
|
+
lines: [
|
|
50
|
+
'- `app.json` extra values (`apiUrl`, `socketUrl`, `socketPath`) configure runtime endpoints.',
|
|
51
|
+
'- Update those values or read from native env as needed.',
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
}),
|
|
56
|
+
...basePackageFiles(),
|
|
57
|
+
};
|
|
58
|
+
await writeFilesIfMissing(ctx.targetDir, files);
|
|
59
|
+
}
|