@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.
- package/package.json +68 -0
- package/src/args.ts +112 -0
- package/src/auth-storage.ts +57 -0
- package/src/buildpacks/index.ts +2 -0
- package/src/buildpacks/registry.ts +47 -0
- package/src/buildpacks/types.ts +1 -0
- package/src/command-registry.ts +700 -0
- package/src/commands/auth.ts +514 -0
- package/src/commands/build.ts +251 -0
- package/src/commands/console.ts +288 -0
- package/src/commands/demo.ts +1154 -0
- package/src/commands/doctor.tsx +133 -0
- package/src/commands/migrate.ts +312 -0
- package/src/commands/project.ts +437 -0
- package/src/commands/target.ts +62 -0
- package/src/commands/typegen.ts +406 -0
- package/src/constants.ts +4 -0
- package/src/control-plane.ts +23 -0
- package/src/dev-logging.tsx +415 -0
- package/src/extensions/index.ts +1 -0
- package/src/extensions/manifest.ts +37 -0
- package/src/help.tsx +236 -0
- package/src/index.ts +4 -0
- package/src/interactive.tsx +306 -0
- package/src/main.tsx +257 -0
- package/src/output.tsx +47 -0
- package/src/paths.ts +11 -0
- package/src/spaces-config.ts +2 -0
- package/src/targets/index.ts +13 -0
- package/src/targets/state.ts +99 -0
- package/src/targets/types.ts +8 -0
- package/src/templates/index.ts +2 -0
- package/src/templates/registry.ts +42 -0
- package/src/templates/syncular-types.ts +10 -0
- package/src/types.ts +67 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import process from 'node:process';
|
|
4
|
+
import { Box, renderToString, Text } from 'ink';
|
|
5
|
+
import type { ReactElement } from 'react';
|
|
6
|
+
|
|
7
|
+
interface DoctorCheck {
|
|
8
|
+
name: 'bun' | 'workspace' | 'git';
|
|
9
|
+
label: string;
|
|
10
|
+
ok: boolean;
|
|
11
|
+
detail: string;
|
|
12
|
+
hint: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function runDoctorChecks(cwd: string): DoctorCheck[] {
|
|
16
|
+
const checks: DoctorCheck[] = [];
|
|
17
|
+
const hasBun = typeof process.versions.bun === 'string';
|
|
18
|
+
const hasWorkspacePackageJson = existsSync(join(cwd, 'package.json'));
|
|
19
|
+
|
|
20
|
+
checks.push({
|
|
21
|
+
name: 'bun',
|
|
22
|
+
label: 'Bun runtime',
|
|
23
|
+
ok: hasBun,
|
|
24
|
+
detail: hasBun ? `Bun ${process.versions.bun}` : 'Bun runtime not detected',
|
|
25
|
+
hint: 'Run the CLI with Bun, for example: bun syncular doctor',
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
checks.push({
|
|
29
|
+
name: 'workspace',
|
|
30
|
+
label: 'Workspace package',
|
|
31
|
+
ok: hasWorkspacePackageJson,
|
|
32
|
+
detail: hasWorkspacePackageJson
|
|
33
|
+
? 'package.json found'
|
|
34
|
+
: 'package.json not found in current directory',
|
|
35
|
+
hint: 'Run this command in the project root or package directory.',
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
let hasGitRepo = false;
|
|
39
|
+
let currentDir = cwd;
|
|
40
|
+
for (let index = 0; index < 8; index += 1) {
|
|
41
|
+
if (existsSync(join(currentDir, '.git'))) {
|
|
42
|
+
hasGitRepo = true;
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
const parentDir = dirname(currentDir);
|
|
46
|
+
if (parentDir === currentDir) {
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
currentDir = parentDir;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
checks.push({
|
|
53
|
+
name: 'git',
|
|
54
|
+
label: 'Git repository',
|
|
55
|
+
ok: hasGitRepo,
|
|
56
|
+
detail: hasGitRepo
|
|
57
|
+
? 'git repository detected'
|
|
58
|
+
: 'git repository not detected near current directory',
|
|
59
|
+
hint: 'Initialize a git repository with: git init',
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
return checks;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function renderDoctorView(args: {
|
|
66
|
+
cwd: string;
|
|
67
|
+
checks: DoctorCheck[];
|
|
68
|
+
}): ReactElement {
|
|
69
|
+
const passedCount = args.checks.reduce(
|
|
70
|
+
(count, check) => count + (check.ok ? 1 : 0),
|
|
71
|
+
0
|
|
72
|
+
);
|
|
73
|
+
const failedCount = args.checks.length - passedCount;
|
|
74
|
+
const labelWidth = args.checks.reduce(
|
|
75
|
+
(maxWidth, check) => Math.max(maxWidth, check.label.length),
|
|
76
|
+
0
|
|
77
|
+
);
|
|
78
|
+
const allPassed = failedCount === 0;
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<Box flexDirection="column">
|
|
82
|
+
<Text color="cyanBright" bold>
|
|
83
|
+
syncular doctor
|
|
84
|
+
</Text>
|
|
85
|
+
<Text color="gray">cwd: {args.cwd}</Text>
|
|
86
|
+
<Box marginTop={1} flexDirection="column">
|
|
87
|
+
{args.checks.map((check) => (
|
|
88
|
+
<Box key={check.name} flexDirection="column">
|
|
89
|
+
<Box>
|
|
90
|
+
<Text color={check.ok ? 'greenBright' : 'redBright'}>
|
|
91
|
+
{check.ok ? 'OK ' : 'FAIL'}
|
|
92
|
+
</Text>
|
|
93
|
+
<Text> {check.label.padEnd(labelWidth)} </Text>
|
|
94
|
+
<Text color={check.ok ? 'gray' : 'white'}>{check.detail}</Text>
|
|
95
|
+
</Box>
|
|
96
|
+
{check.ok ? null : (
|
|
97
|
+
<Box marginLeft={6}>
|
|
98
|
+
<Text color="yellow">hint: </Text>
|
|
99
|
+
<Text color="yellowBright">{check.hint}</Text>
|
|
100
|
+
</Box>
|
|
101
|
+
)}
|
|
102
|
+
</Box>
|
|
103
|
+
))}
|
|
104
|
+
</Box>
|
|
105
|
+
<Box marginTop={1}>
|
|
106
|
+
<Text color={allPassed ? 'greenBright' : 'yellowBright'}>
|
|
107
|
+
{allPassed
|
|
108
|
+
? `All checks passed (${passedCount}/${args.checks.length}).`
|
|
109
|
+
: `Checks passed: ${passedCount}/${args.checks.length}.`}
|
|
110
|
+
</Text>
|
|
111
|
+
</Box>
|
|
112
|
+
</Box>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function runDoctor(): Promise<number> {
|
|
117
|
+
const cwd = process.cwd();
|
|
118
|
+
const checks = runDoctorChecks(cwd);
|
|
119
|
+
const rendered = renderToString(
|
|
120
|
+
renderDoctorView({
|
|
121
|
+
cwd,
|
|
122
|
+
checks,
|
|
123
|
+
}),
|
|
124
|
+
{
|
|
125
|
+
columns: Math.max(process.stdout.columns ?? 120, 120),
|
|
126
|
+
}
|
|
127
|
+
);
|
|
128
|
+
process.stdout.write(rendered);
|
|
129
|
+
if (!rendered.endsWith('\n')) {
|
|
130
|
+
process.stdout.write('\n');
|
|
131
|
+
}
|
|
132
|
+
return checks.every((check) => check.ok) ? 0 : 1;
|
|
133
|
+
}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, resolve } from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { printError } from '../output';
|
|
5
|
+
|
|
6
|
+
type ChecksumMismatchMode = 'error' | 'reset';
|
|
7
|
+
|
|
8
|
+
interface MigrationStatusInput {
|
|
9
|
+
cwd: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface MigrationUpInput extends MigrationStatusInput {
|
|
13
|
+
onChecksumMismatch: ChecksumMismatchMode;
|
|
14
|
+
dryRun: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface MigrationStatusResult {
|
|
18
|
+
currentVersion: number;
|
|
19
|
+
targetVersion: number;
|
|
20
|
+
pendingVersions: number[];
|
|
21
|
+
trackingTable?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface MigrationUpResult {
|
|
25
|
+
appliedVersions: number[];
|
|
26
|
+
currentVersion: number;
|
|
27
|
+
wasReset?: boolean;
|
|
28
|
+
dryRun?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface MigrationAdapter {
|
|
32
|
+
status(input: MigrationStatusInput): Promise<MigrationStatusResult>;
|
|
33
|
+
up(input: MigrationUpInput): Promise<MigrationUpResult>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface SyncularCliConfig {
|
|
37
|
+
migrate?: {
|
|
38
|
+
adapter: string;
|
|
39
|
+
export?: string;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const DEFAULT_CONFIG_PATH = 'syncular.config.json';
|
|
44
|
+
const DEFAULT_MIGRATE_EXPORT = 'migrationRunner';
|
|
45
|
+
|
|
46
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
47
|
+
return typeof value === 'object' && value !== null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseConfig(value: unknown): SyncularCliConfig | { error: string } {
|
|
51
|
+
if (!isRecord(value)) {
|
|
52
|
+
return { error: 'Config must be a JSON object.' };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const config: SyncularCliConfig = {};
|
|
56
|
+
const migrateValue = value.migrate;
|
|
57
|
+
if (migrateValue !== undefined) {
|
|
58
|
+
if (!isRecord(migrateValue)) {
|
|
59
|
+
return { error: 'Config field "migrate" must be an object.' };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const adapter = migrateValue.adapter;
|
|
63
|
+
const exportName = migrateValue.export;
|
|
64
|
+
if (typeof adapter !== 'string' || adapter.length === 0) {
|
|
65
|
+
return {
|
|
66
|
+
error: 'Config field "migrate.adapter" must be a non-empty string.',
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
if (
|
|
70
|
+
exportName !== undefined &&
|
|
71
|
+
(typeof exportName !== 'string' || exportName.length === 0)
|
|
72
|
+
) {
|
|
73
|
+
return {
|
|
74
|
+
error:
|
|
75
|
+
'Config field "migrate.export" must be a non-empty string when provided.',
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
config.migrate = {
|
|
80
|
+
adapter,
|
|
81
|
+
...(exportName ? { export: exportName } : {}),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return config;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function loadConfig(
|
|
89
|
+
configPath: string
|
|
90
|
+
): Promise<{ config: SyncularCliConfig } | { error: string }> {
|
|
91
|
+
let raw = '';
|
|
92
|
+
try {
|
|
93
|
+
raw = await readFile(configPath, 'utf8');
|
|
94
|
+
} catch {
|
|
95
|
+
return {
|
|
96
|
+
error: `Config not found at ${configPath}. Run "syncular create --template syncular-libraries" first.`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let parsed: unknown;
|
|
101
|
+
try {
|
|
102
|
+
parsed = JSON.parse(raw);
|
|
103
|
+
} catch {
|
|
104
|
+
return { error: `Config at ${configPath} is not valid JSON.` };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const normalized = parseConfig(parsed);
|
|
108
|
+
if ('error' in normalized) {
|
|
109
|
+
return { error: `${normalized.error} (path: ${configPath})` };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return { config: normalized };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isMigrationAdapter(value: unknown): value is MigrationAdapter {
|
|
116
|
+
if (!isRecord(value)) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
return typeof value.status === 'function' && typeof value.up === 'function';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function loadMigrationAdapter(args: {
|
|
123
|
+
configPath: string;
|
|
124
|
+
}): Promise<{ adapter: MigrationAdapter } | { error: string }> {
|
|
125
|
+
const loaded = await loadConfig(args.configPath);
|
|
126
|
+
if ('error' in loaded) {
|
|
127
|
+
return loaded;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const migrate = loaded.config.migrate;
|
|
131
|
+
if (!migrate) {
|
|
132
|
+
return {
|
|
133
|
+
error:
|
|
134
|
+
'Config does not define "migrate". Run "syncular create --template syncular-libraries" to scaffold adapter files.',
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const adapterModulePath = resolve(dirname(args.configPath), migrate.adapter);
|
|
139
|
+
const adapterExportName = migrate.export ?? DEFAULT_MIGRATE_EXPORT;
|
|
140
|
+
|
|
141
|
+
let moduleValue: unknown;
|
|
142
|
+
try {
|
|
143
|
+
moduleValue = await import(pathToFileURL(adapterModulePath).href);
|
|
144
|
+
} catch (error: unknown) {
|
|
145
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
146
|
+
return {
|
|
147
|
+
error: `Failed to load migrate adapter module (${adapterModulePath}): ${message}`,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!isRecord(moduleValue)) {
|
|
152
|
+
return {
|
|
153
|
+
error: `Adapter module ${adapterModulePath} did not load as an object module.`,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const exportValue = moduleValue[adapterExportName];
|
|
158
|
+
if (!isMigrationAdapter(exportValue)) {
|
|
159
|
+
return {
|
|
160
|
+
error:
|
|
161
|
+
`Adapter export "${adapterExportName}" in ${adapterModulePath} is missing ` +
|
|
162
|
+
'required methods: status() and up().',
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return { adapter: exportValue };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function optionalFlag(
|
|
170
|
+
flagValues: Map<string, string>,
|
|
171
|
+
flag: string,
|
|
172
|
+
fallback: string
|
|
173
|
+
): string {
|
|
174
|
+
const value = flagValues.get(flag)?.trim();
|
|
175
|
+
return value && value.length > 0 ? value : fallback;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function optionalBooleanFlag(
|
|
179
|
+
flagValues: Map<string, string>,
|
|
180
|
+
flag: string,
|
|
181
|
+
fallback: boolean
|
|
182
|
+
): boolean {
|
|
183
|
+
const value = flagValues.get(flag);
|
|
184
|
+
if (!value) {
|
|
185
|
+
return fallback;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const normalized = value.trim().toLowerCase();
|
|
189
|
+
if (
|
|
190
|
+
normalized === '1' ||
|
|
191
|
+
normalized === 'true' ||
|
|
192
|
+
normalized === 'yes' ||
|
|
193
|
+
normalized === 'on'
|
|
194
|
+
) {
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
if (
|
|
198
|
+
normalized === '0' ||
|
|
199
|
+
normalized === 'false' ||
|
|
200
|
+
normalized === 'no' ||
|
|
201
|
+
normalized === 'off'
|
|
202
|
+
) {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
throw new Error(
|
|
207
|
+
`Invalid ${flag} value "${value}". Use true/false, yes/no, on/off, or 1/0.`
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function parseChecksumMode(
|
|
212
|
+
flagValues: Map<string, string>
|
|
213
|
+
): ChecksumMismatchMode | { error: string } {
|
|
214
|
+
const value = flagValues.get('--on-checksum-mismatch');
|
|
215
|
+
if (!value) {
|
|
216
|
+
return 'error';
|
|
217
|
+
}
|
|
218
|
+
if (value === 'error' || value === 'reset') {
|
|
219
|
+
return value;
|
|
220
|
+
}
|
|
221
|
+
return {
|
|
222
|
+
error: 'Invalid value for --on-checksum-mismatch. Use "error" or "reset".',
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function resolveConfigPath(
|
|
227
|
+
flagValues: Map<string, string>,
|
|
228
|
+
cwd: string
|
|
229
|
+
): string {
|
|
230
|
+
const configPath = optionalFlag(flagValues, '--config', DEFAULT_CONFIG_PATH);
|
|
231
|
+
return resolve(cwd, configPath);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export async function runMigrateStatus(
|
|
235
|
+
flagValues: Map<string, string>
|
|
236
|
+
): Promise<number> {
|
|
237
|
+
try {
|
|
238
|
+
const cwd = process.cwd();
|
|
239
|
+
const configPath = resolveConfigPath(flagValues, cwd);
|
|
240
|
+
const loaded = await loadMigrationAdapter({ configPath });
|
|
241
|
+
if ('error' in loaded) {
|
|
242
|
+
throw new Error(loaded.error);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const result = await loaded.adapter.status({ cwd });
|
|
246
|
+
const pendingVersions =
|
|
247
|
+
result.pendingVersions.length === 0
|
|
248
|
+
? '(none)'
|
|
249
|
+
: result.pendingVersions.join(', ');
|
|
250
|
+
|
|
251
|
+
console.log(`Current version: ${result.currentVersion}`);
|
|
252
|
+
console.log(`Target version: ${result.targetVersion}`);
|
|
253
|
+
console.log(`Pending versions: ${pendingVersions}`);
|
|
254
|
+
if (result.trackingTable) {
|
|
255
|
+
console.log(`Tracking table: ${result.trackingTable}`);
|
|
256
|
+
}
|
|
257
|
+
return 0;
|
|
258
|
+
} catch (error: unknown) {
|
|
259
|
+
printError(
|
|
260
|
+
error instanceof Error ? error.message : 'Migration status failed.'
|
|
261
|
+
);
|
|
262
|
+
return 1;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export async function runMigrateUp(
|
|
267
|
+
flagValues: Map<string, string>
|
|
268
|
+
): Promise<number> {
|
|
269
|
+
try {
|
|
270
|
+
const checksumMode = parseChecksumMode(flagValues);
|
|
271
|
+
if (typeof checksumMode !== 'string') {
|
|
272
|
+
throw new Error(checksumMode.error);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const confirmReset = optionalBooleanFlag(flagValues, '--yes', false);
|
|
276
|
+
if (checksumMode === 'reset' && !confirmReset) {
|
|
277
|
+
throw new Error(
|
|
278
|
+
'Reset mode requires explicit confirmation. Re-run with --on-checksum-mismatch reset --yes true.'
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const cwd = process.cwd();
|
|
283
|
+
const dryRun = optionalBooleanFlag(flagValues, '--dry-run', false);
|
|
284
|
+
const configPath = resolveConfigPath(flagValues, cwd);
|
|
285
|
+
const loaded = await loadMigrationAdapter({ configPath });
|
|
286
|
+
if ('error' in loaded) {
|
|
287
|
+
throw new Error(loaded.error);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const result = await loaded.adapter.up({
|
|
291
|
+
cwd,
|
|
292
|
+
onChecksumMismatch: checksumMode,
|
|
293
|
+
dryRun,
|
|
294
|
+
});
|
|
295
|
+
const appliedVersions =
|
|
296
|
+
result.appliedVersions.length === 0
|
|
297
|
+
? '(none)'
|
|
298
|
+
: result.appliedVersions.join(', ');
|
|
299
|
+
|
|
300
|
+
console.log(`Applied versions: ${appliedVersions}`);
|
|
301
|
+
console.log(`Current version: ${result.currentVersion}`);
|
|
302
|
+
console.log(`Checksum mismatch mode: ${checksumMode}`);
|
|
303
|
+
console.log(`Reset occurred: ${result.wasReset ? 'yes' : 'no'}`);
|
|
304
|
+
if (result.dryRun || dryRun) {
|
|
305
|
+
console.log('Dry run: yes');
|
|
306
|
+
}
|
|
307
|
+
return 0;
|
|
308
|
+
} catch (error: unknown) {
|
|
309
|
+
printError(error instanceof Error ? error.message : 'Migration up failed.');
|
|
310
|
+
return 1;
|
|
311
|
+
}
|
|
312
|
+
}
|