@syncular/cli 0.0.0-44

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,306 @@
1
+ import { Box, Text, useApp, useInput } from 'ink';
2
+ import type { ReactElement } from 'react';
3
+ import { useMemo, useState } from 'react';
4
+ import { listInteractiveCommands } from './command-registry';
5
+ import { CLI_NAME, CLI_VERSION } from './constants';
6
+ import type { InteractiveCommand, RootCommand } from './types';
7
+
8
+ interface InteractiveAppProps {
9
+ initialCommand: RootCommand | null;
10
+ allowForms: boolean;
11
+ onSubmit(argv: string[]): void;
12
+ }
13
+
14
+ type Screen = 'menu' | 'form';
15
+
16
+ function parseBooleanInput(value: string): boolean | null {
17
+ const normalized = value.trim().toLowerCase();
18
+ if (
19
+ normalized === 'true' ||
20
+ normalized === '1' ||
21
+ normalized === 'yes' ||
22
+ normalized === 'on'
23
+ ) {
24
+ return true;
25
+ }
26
+ if (
27
+ normalized === 'false' ||
28
+ normalized === '0' ||
29
+ normalized === 'no' ||
30
+ normalized === 'off'
31
+ ) {
32
+ return false;
33
+ }
34
+ return null;
35
+ }
36
+
37
+ function validateFields(
38
+ command: InteractiveCommand,
39
+ values: Record<string, string>
40
+ ): string | null {
41
+ for (const field of command.fields ?? []) {
42
+ const value = values[field.id] ?? '';
43
+ if (field.required && value.trim().length === 0) {
44
+ return `${field.label} is required.`;
45
+ }
46
+
47
+ if (field.kind === 'number' && value.trim().length > 0) {
48
+ const parsed = Number(value.trim());
49
+ if (!Number.isFinite(parsed)) {
50
+ return `${field.label} must be a number.`;
51
+ }
52
+ }
53
+
54
+ if (field.kind === 'boolean' && value.trim().length > 0) {
55
+ if (parseBooleanInput(value) === null) {
56
+ return `${field.label} must be true/false, yes/no, on/off, or 1/0.`;
57
+ }
58
+ }
59
+ }
60
+ return null;
61
+ }
62
+
63
+ export function InteractiveApp(props: InteractiveAppProps): ReactElement {
64
+ const { exit } = useApp();
65
+ const commands = useMemo(
66
+ () => listInteractiveCommands(props.initialCommand),
67
+ [props.initialCommand]
68
+ );
69
+ const [screen, setScreen] = useState<Screen>('menu');
70
+ const [selectedIndex, setSelectedIndex] = useState(0);
71
+ const [activeCommand, setActiveCommand] = useState<InteractiveCommand | null>(
72
+ null
73
+ );
74
+ const [activeFieldIndex, setActiveFieldIndex] = useState(0);
75
+ const [fieldValues, setFieldValues] = useState<Record<string, string>>({});
76
+ const [hint, setHint] = useState<string | null>(null);
77
+
78
+ const submitArgv = (argv: string[] | null): void => {
79
+ if (!argv) {
80
+ exit();
81
+ return;
82
+ }
83
+ props.onSubmit(argv);
84
+ exit();
85
+ };
86
+
87
+ const startForm = (command: InteractiveCommand): void => {
88
+ const initialValues: Record<string, string> = {};
89
+ for (const field of command.fields ?? []) {
90
+ initialValues[field.id] = field.defaultValue ?? '';
91
+ }
92
+ setActiveCommand(command);
93
+ setActiveFieldIndex(0);
94
+ setFieldValues(initialValues);
95
+ setHint(null);
96
+ setScreen('form');
97
+ };
98
+
99
+ const submitForm = (): void => {
100
+ if (!activeCommand) {
101
+ return;
102
+ }
103
+
104
+ const validationError = validateFields(activeCommand, fieldValues);
105
+ if (validationError) {
106
+ setHint(validationError);
107
+ return;
108
+ }
109
+
110
+ submitArgv(activeCommand.buildArgv(fieldValues));
111
+ };
112
+
113
+ useInput((input, key) => {
114
+ if (input.toLowerCase() === 'q') {
115
+ exit();
116
+ return;
117
+ }
118
+
119
+ if (screen === 'menu') {
120
+ if (key.upArrow) {
121
+ setSelectedIndex((index) =>
122
+ index === 0 ? commands.length - 1 : index - 1
123
+ );
124
+ return;
125
+ }
126
+
127
+ if (key.downArrow) {
128
+ setSelectedIndex((index) =>
129
+ index === commands.length - 1 ? 0 : index + 1
130
+ );
131
+ return;
132
+ }
133
+
134
+ if (!key.return) {
135
+ return;
136
+ }
137
+
138
+ const selected = commands[selectedIndex];
139
+ if (!selected) {
140
+ return;
141
+ }
142
+
143
+ if ((selected.fields?.length ?? 0) === 0) {
144
+ submitArgv(selected.buildArgv({}));
145
+ return;
146
+ }
147
+
148
+ if (!props.allowForms) {
149
+ submitArgv(selected.buildArgv({}));
150
+ return;
151
+ }
152
+
153
+ startForm(selected);
154
+ return;
155
+ }
156
+
157
+ if (!activeCommand || screen !== 'form') {
158
+ return;
159
+ }
160
+
161
+ if (key.escape) {
162
+ setScreen('menu');
163
+ setActiveCommand(null);
164
+ setHint(null);
165
+ return;
166
+ }
167
+
168
+ const fields = activeCommand.fields ?? [];
169
+ if (fields.length === 0) {
170
+ submitArgv(activeCommand.buildArgv(fieldValues));
171
+ return;
172
+ }
173
+
174
+ const activeField = fields[activeFieldIndex];
175
+ if (!activeField) {
176
+ return;
177
+ }
178
+
179
+ if (key.upArrow) {
180
+ setActiveFieldIndex((index) =>
181
+ index === 0 ? fields.length - 1 : index - 1
182
+ );
183
+ return;
184
+ }
185
+
186
+ if (key.downArrow || key.tab) {
187
+ setActiveFieldIndex((index) =>
188
+ index === fields.length - 1 ? 0 : index + 1
189
+ );
190
+ return;
191
+ }
192
+
193
+ if (key.ctrl && input.toLowerCase() === 'r') {
194
+ submitForm();
195
+ return;
196
+ }
197
+
198
+ if (key.backspace || key.delete) {
199
+ setFieldValues((current) => ({
200
+ ...current,
201
+ [activeField.id]: (current[activeField.id] ?? '').slice(0, -1),
202
+ }));
203
+ setHint(null);
204
+ return;
205
+ }
206
+
207
+ if (key.return) {
208
+ if (activeFieldIndex === fields.length - 1) {
209
+ submitForm();
210
+ } else {
211
+ setActiveFieldIndex((index) => index + 1);
212
+ }
213
+ return;
214
+ }
215
+
216
+ if (!input || key.ctrl || key.meta) {
217
+ return;
218
+ }
219
+
220
+ setFieldValues((current) => ({
221
+ ...current,
222
+ [activeField.id]: `${current[activeField.id] ?? ''}${input}`,
223
+ }));
224
+ setHint(null);
225
+ });
226
+
227
+ return (
228
+ <Box flexDirection="column">
229
+ <Box borderStyle="round" borderColor="cyan" paddingX={1} paddingY={0}>
230
+ <Text color="cyanBright">
231
+ {CLI_NAME} interactive {`v${CLI_VERSION}`}
232
+ </Text>
233
+ </Box>
234
+ <Text color="gray">
235
+ {screen === 'menu'
236
+ ? props.allowForms
237
+ ? 'Up/Down: select Enter: open form q: quit'
238
+ : 'Up/Down: select Enter: run q: quit (use --forms for input forms)'
239
+ : 'Type to edit Up/Down: field Enter: next/run Ctrl+R: run Esc: back'}
240
+ </Text>
241
+ <Box marginTop={1}>
242
+ {screen === 'menu' ? (
243
+ <Box flexDirection="column" width={88}>
244
+ {commands.map((command, index) => {
245
+ const active = index === selectedIndex;
246
+ return (
247
+ <Box key={command.id}>
248
+ <Text color={active ? 'greenBright' : undefined}>
249
+ {active ? '> ' : ' '}
250
+ {command.label.padEnd(18)}
251
+ </Text>
252
+ <Text color={active ? 'white' : 'gray'}>
253
+ {command.description}
254
+ </Text>
255
+ </Box>
256
+ );
257
+ })}
258
+ </Box>
259
+ ) : (
260
+ <Box
261
+ flexDirection="column"
262
+ borderStyle="single"
263
+ borderColor="green"
264
+ paddingX={1}
265
+ paddingY={0}
266
+ width={88}
267
+ >
268
+ <Text color="greenBright">{activeCommand?.label}</Text>
269
+ <Box marginTop={1} flexDirection="column">
270
+ {(activeCommand?.fields ?? []).map((field, index) => {
271
+ const active = index === activeFieldIndex;
272
+ const value = fieldValues[field.id] ?? '';
273
+ const required = field.required ? ' *' : '';
274
+ return (
275
+ <Box key={field.id}>
276
+ <Text color={active ? 'yellowBright' : 'cyan'}>
277
+ {active ? '> ' : ' '}
278
+ {field.label}
279
+ {required}:{' '}
280
+ </Text>
281
+ <Text color={value.length > 0 ? 'white' : 'gray'}>
282
+ {value.length > 0
283
+ ? value
284
+ : (field.placeholder ?? field.defaultValue ?? '')}
285
+ </Text>
286
+ </Box>
287
+ );
288
+ })}
289
+ </Box>
290
+ <Box marginTop={1}>
291
+ <Text color="gray">
292
+ command:{' '}
293
+ {(activeCommand?.buildArgv(fieldValues) ?? ['']).join(' ')}
294
+ </Text>
295
+ </Box>
296
+ {hint ? (
297
+ <Box marginTop={1}>
298
+ <Text color="redBright">{hint}</Text>
299
+ </Box>
300
+ ) : null}
301
+ </Box>
302
+ )}
303
+ </Box>
304
+ </Box>
305
+ );
306
+ }
package/src/main.tsx ADDED
@@ -0,0 +1,257 @@
1
+ import process from 'node:process';
2
+ import { render } from 'ink';
3
+ import { parseArgs, shouldRunInteractive } from './args';
4
+ import { runLogin, runLogout, runWhoAmI } from './commands/auth';
5
+ import { runBuild, runEject } from './commands/build';
6
+ import { runConsole } from './commands/console';
7
+ import {
8
+ runCreateSpace,
9
+ runDeploy,
10
+ runDeployments,
11
+ runRollback,
12
+ runVerify,
13
+ } from './commands/demo';
14
+ import { runDoctor } from './commands/doctor';
15
+ import { runMigrateStatus, runMigrateUp } from './commands/migrate';
16
+ import { runCreate, runDev } from './commands/project';
17
+ import { runTarget } from './commands/target';
18
+ import { runTypegen } from './commands/typegen';
19
+ import { printHelp } from './help';
20
+ import { InteractiveApp } from './interactive';
21
+ import { printError, printHeader } from './output';
22
+ import type { ParsedArgs } from './types';
23
+
24
+ type CommandRunner = (flagValues: Map<string, string>) => Promise<number>;
25
+ type LogError = (message: string) => void;
26
+
27
+ export interface NonInteractiveHandlers {
28
+ printHelp: () => void;
29
+ printHeader: () => void;
30
+ printError: LogError;
31
+ runDoctor: () => Promise<number>;
32
+ runConsole: CommandRunner;
33
+ runLogin: CommandRunner;
34
+ runLogout: CommandRunner;
35
+ runWhoAmI: CommandRunner;
36
+ runCreateSpace: CommandRunner;
37
+ runCreate: CommandRunner;
38
+ runDev: CommandRunner;
39
+ runTypegen: CommandRunner;
40
+ runMigrateStatus: CommandRunner;
41
+ runMigrateUp: CommandRunner;
42
+ runBuild: CommandRunner;
43
+ runEject: CommandRunner;
44
+ runTarget: CommandRunner;
45
+ runDeploy: CommandRunner;
46
+ runVerify: CommandRunner;
47
+ runDeployments: CommandRunner;
48
+ runRollback: CommandRunner;
49
+ }
50
+
51
+ const defaultNonInteractiveHandlers: NonInteractiveHandlers = {
52
+ printHelp,
53
+ printHeader,
54
+ printError,
55
+ runDoctor,
56
+ runConsole,
57
+ runLogin,
58
+ runLogout,
59
+ runWhoAmI,
60
+ runCreateSpace,
61
+ runCreate,
62
+ runDev,
63
+ runTypegen,
64
+ runMigrateStatus,
65
+ runMigrateUp,
66
+ runBuild,
67
+ runEject,
68
+ runTarget,
69
+ runDeploy,
70
+ runVerify,
71
+ runDeployments,
72
+ runRollback,
73
+ };
74
+
75
+ interface RootRoute {
76
+ command:
77
+ | 'doctor'
78
+ | 'console'
79
+ | 'login'
80
+ | 'logout'
81
+ | 'whoami'
82
+ | 'create-space'
83
+ | 'create'
84
+ | 'dev'
85
+ | 'typegen'
86
+ | 'migrate-status'
87
+ | 'migrate-up'
88
+ | 'build'
89
+ | 'eject'
90
+ | 'target'
91
+ | 'deploy'
92
+ | 'verify'
93
+ | 'deployments'
94
+ | 'rollback';
95
+ run(
96
+ handlers: NonInteractiveHandlers,
97
+ flagValues: Map<string, string>
98
+ ): Promise<number>;
99
+ }
100
+
101
+ const ROOT_ROUTES: RootRoute[] = [
102
+ { command: 'doctor', run: (handlers) => handlers.runDoctor() },
103
+ { command: 'console', run: (handlers, flags) => handlers.runConsole(flags) },
104
+ { command: 'login', run: (handlers, flags) => handlers.runLogin(flags) },
105
+ { command: 'logout', run: (handlers, flags) => handlers.runLogout(flags) },
106
+ { command: 'whoami', run: (handlers, flags) => handlers.runWhoAmI(flags) },
107
+ {
108
+ command: 'create-space',
109
+ run: (handlers, flags) => handlers.runCreateSpace(flags),
110
+ },
111
+ { command: 'create', run: (handlers, flags) => handlers.runCreate(flags) },
112
+ { command: 'dev', run: (handlers, flags) => handlers.runDev(flags) },
113
+ { command: 'typegen', run: (handlers, flags) => handlers.runTypegen(flags) },
114
+ {
115
+ command: 'migrate-status',
116
+ run: (handlers, flags) => handlers.runMigrateStatus(flags),
117
+ },
118
+ {
119
+ command: 'migrate-up',
120
+ run: (handlers, flags) => handlers.runMigrateUp(flags),
121
+ },
122
+ { command: 'build', run: (handlers, flags) => handlers.runBuild(flags) },
123
+ { command: 'eject', run: (handlers, flags) => handlers.runEject(flags) },
124
+ { command: 'target', run: (handlers, flags) => handlers.runTarget(flags) },
125
+ { command: 'deploy', run: (handlers, flags) => handlers.runDeploy(flags) },
126
+ { command: 'verify', run: (handlers, flags) => handlers.runVerify(flags) },
127
+ {
128
+ command: 'deployments',
129
+ run: (handlers, flags) => handlers.runDeployments(flags),
130
+ },
131
+ {
132
+ command: 'rollback',
133
+ run: (handlers, flags) => handlers.runRollback(flags),
134
+ },
135
+ ];
136
+
137
+ function findRootRoute(command: ParsedArgs['command']): RootRoute | null {
138
+ return ROOT_ROUTES.find((route) => route.command === command) ?? null;
139
+ }
140
+
141
+ export async function runNonInteractive(
142
+ args: ParsedArgs,
143
+ handlers: NonInteractiveHandlers = defaultNonInteractiveHandlers
144
+ ): Promise<number> {
145
+ if (args.flags.has('--help')) {
146
+ handlers.printHelp();
147
+ return 0;
148
+ }
149
+
150
+ if (args.flags.has('--version')) {
151
+ handlers.printHeader();
152
+ return 0;
153
+ }
154
+
155
+ if (args.command === 'help') {
156
+ handlers.printHelp();
157
+ return 0;
158
+ }
159
+
160
+ if (args.command === 'version') {
161
+ handlers.printHeader();
162
+ return 0;
163
+ }
164
+
165
+ const rootRoute = findRootRoute(args.command);
166
+ if (rootRoute) {
167
+ if (args.subcommand !== null) {
168
+ handlers.printError(
169
+ `Unexpected positional argument for "${rootRoute.command}": ${args.subcommand}`
170
+ );
171
+ handlers.printHelp();
172
+ return 1;
173
+ }
174
+ return rootRoute.run(handlers, args.flagValues);
175
+ }
176
+
177
+ if (args.command === 'interactive') {
178
+ handlers.printError('Interactive mode requires a TTY.');
179
+ return 1;
180
+ }
181
+
182
+ if (args.command === null && args.positionals.length > 0) {
183
+ handlers.printError(`Unknown command: ${args.positionals[0]}`);
184
+ handlers.printHelp();
185
+ return 1;
186
+ }
187
+
188
+ handlers.printHelp();
189
+ return 0;
190
+ }
191
+
192
+ async function runInteractive(
193
+ initialCommand: ParsedArgs['command'],
194
+ allowForms: boolean
195
+ ): Promise<string[] | null> {
196
+ return new Promise((resolve) => {
197
+ let settled = false;
198
+ const app = render(
199
+ <InteractiveApp
200
+ initialCommand={initialCommand}
201
+ allowForms={allowForms}
202
+ onSubmit={(argv) => {
203
+ if (settled) {
204
+ return;
205
+ }
206
+ settled = true;
207
+ resolve(argv);
208
+ app.unmount();
209
+ }}
210
+ />
211
+ );
212
+
213
+ void app.waitUntilExit().then(() => {
214
+ if (settled) {
215
+ return;
216
+ }
217
+ settled = true;
218
+ resolve(null);
219
+ });
220
+ });
221
+ }
222
+
223
+ export async function main(): Promise<void> {
224
+ const argv = process.argv.slice(2);
225
+ let args = parseArgs(argv);
226
+
227
+ const helpOrVersionRequested =
228
+ args.flags.has('--help') ||
229
+ args.flags.has('--version') ||
230
+ args.command === 'help' ||
231
+ args.command === 'version';
232
+
233
+ const interactiveRequested =
234
+ args.flags.has('--interactive') ||
235
+ args.command === 'interactive' ||
236
+ (args.command === null && args.positionals.length === 0) ||
237
+ (args.command === 'create' && !args.flagValues.has('--template'));
238
+
239
+ if (
240
+ !helpOrVersionRequested &&
241
+ interactiveRequested &&
242
+ shouldRunInteractive(args)
243
+ ) {
244
+ const selectedArgv = await runInteractive(
245
+ args.command,
246
+ args.flags.has('--forms')
247
+ );
248
+ if (selectedArgv === null) {
249
+ process.exit(0);
250
+ }
251
+
252
+ args = parseArgs(selectedArgv);
253
+ }
254
+
255
+ const exitCode = await runNonInteractive(args);
256
+ process.exit(exitCode);
257
+ }
package/src/output.tsx ADDED
@@ -0,0 +1,47 @@
1
+ import process from 'node:process';
2
+ import { Box, renderToString, Text } from 'ink';
3
+ import type { ReactElement } from 'react';
4
+ import { CLI_NAME, CLI_VERSION } from './constants';
5
+
6
+ function printInk(view: ReactElement): void {
7
+ const rendered = renderToString(view, {
8
+ columns: Math.max(process.stdout.columns ?? 120, 120),
9
+ });
10
+ process.stdout.write(rendered);
11
+ if (!rendered.endsWith('\n')) {
12
+ process.stdout.write('\n');
13
+ }
14
+ }
15
+
16
+ export function printHeader(): void {
17
+ printInk(
18
+ <Text color="cyanBright" bold>
19
+ {CLI_NAME} CLI v{CLI_VERSION}
20
+ </Text>
21
+ );
22
+ }
23
+
24
+ export function printInfo(message: string): void {
25
+ printInk(
26
+ <Box>
27
+ <Text color="cyan">info </Text>
28
+ <Text>{message}</Text>
29
+ </Box>
30
+ );
31
+ }
32
+
33
+ export function printError(message: string): void {
34
+ const rendered = renderToString(
35
+ <Box>
36
+ <Text color="redBright">error </Text>
37
+ <Text>{message}</Text>
38
+ </Box>,
39
+ {
40
+ columns: Math.max(process.stderr.columns ?? 120, 120),
41
+ }
42
+ );
43
+ process.stderr.write(rendered);
44
+ if (!rendered.endsWith('\n')) {
45
+ process.stderr.write('\n');
46
+ }
47
+ }
package/src/paths.ts ADDED
@@ -0,0 +1,11 @@
1
+ import { dirname, resolve } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+
4
+ export function resolveRepoRoot(): string {
5
+ const commandDir = dirname(fileURLToPath(import.meta.url));
6
+ return resolve(commandDir, '..', '..', '..');
7
+ }
8
+
9
+ export function resolveDefaultSyncularConfigPath(cwd: string): string {
10
+ return resolve(cwd, 'syncular.config.ts');
11
+ }
@@ -0,0 +1,2 @@
1
+ export const DEFAULT_CONTROL_PLANE_API_BASE =
2
+ 'https://api.spaces.syncular.dev/api/control';
@@ -0,0 +1,13 @@
1
+ import { listTargetIds } from './state';
2
+ import type { TargetDefinition } from './types';
3
+
4
+ export * from './state';
5
+ export * from './types';
6
+
7
+ export function listTargetDefinitions(): TargetDefinition[] {
8
+ return listTargetIds().map((id) => ({
9
+ id,
10
+ description: 'Managed deploys for Spaces.',
11
+ defaultBuildpackId: 'contract-worker',
12
+ }));
13
+ }