@syncular/cli 0.0.0-108 → 0.0.0-113

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.
@@ -1,1151 +0,0 @@
1
- import { execFileSync } from 'node:child_process';
2
- import { randomUUID } from 'node:crypto';
3
- import { existsSync, readFileSync } from 'node:fs';
4
- import { resolve } from 'node:path';
5
- import { getBuildpackById } from '../buildpacks';
6
- import { parseBearerToken, resolveControlPlaneBase } from '../control-plane';
7
- import { resolveControlPlaneToken } from '../control-plane-token';
8
- import {
9
- optionalBooleanFlag,
10
- optionalEnumFlag,
11
- optionalFlag,
12
- optionalIntegerFlag,
13
- } from '../flags';
14
- import { printError, printInfo } from '../output';
15
- import { resolveDefaultSyncularConfigPath, resolveRepoRoot } from '../paths';
16
- import { resolveEffectiveTargetId } from '../targets';
17
-
18
- interface RuntimeResponse {
19
- runtime?: {
20
- workerName?: string;
21
- hostname?: string;
22
- baseUrl?: string;
23
- syncUrl?: string;
24
- consoleToken?: string;
25
- databaseProvider?: 'sqlite' | 'neon' | 'postgres';
26
- };
27
- error?: string;
28
- message?: string;
29
- }
30
-
31
- interface RuntimeInfo {
32
- workerName: string;
33
- hostname: string;
34
- baseUrl: string;
35
- syncUrl: string;
36
- consoleToken: string | null;
37
- databaseProvider: 'sqlite' | 'neon' | 'postgres';
38
- }
39
-
40
- interface DeploymentResponse {
41
- deployment?: {
42
- id?: string;
43
- scriptName?: string;
44
- status?: string;
45
- createdAt?: string;
46
- artifactHash?: string | null;
47
- rollbackOfDeploymentId?: string | null;
48
- };
49
- error?: string;
50
- message?: string;
51
- }
52
-
53
- interface DeploymentsResponse {
54
- deployments?: Array<{
55
- id?: string;
56
- scriptName?: string;
57
- source?: string;
58
- status?: string;
59
- createdAt?: string;
60
- artifactHash?: string | null;
61
- rollbackOfDeploymentId?: string | null;
62
- }>;
63
- error?: string;
64
- message?: string;
65
- }
66
-
67
- interface DeployArtifactResponse {
68
- artifact?: {
69
- id?: string;
70
- scriptName?: string;
71
- artifactHash?: string;
72
- source?: string;
73
- byteSize?: number;
74
- createdAt?: string;
75
- };
76
- error?: string;
77
- message?: string;
78
- }
79
-
80
- interface DeployJobResponse {
81
- job?: {
82
- id?: string;
83
- artifactId?: string;
84
- deploymentId?: string | null;
85
- scriptName?: string;
86
- status?:
87
- | 'queued'
88
- | 'preparing'
89
- | 'deploying'
90
- | 'verifying'
91
- | 'activating'
92
- | 'completed'
93
- | 'failed';
94
- errorText?: string | null;
95
- createdAt?: string;
96
- updatedAt?: string;
97
- completedAt?: string | null;
98
- };
99
- error?: string;
100
- message?: string;
101
- }
102
-
103
- type SpaceDbProvider = 'sqlite' | 'neon' | 'postgres';
104
- type SpaceRegion = 'auto' | 'enam' | 'wnam' | 'weur' | 'eeur' | 'apac' | 'oc';
105
-
106
- interface CreateSpaceResponse {
107
- space?: {
108
- id?: string;
109
- name?: string;
110
- };
111
- provisionJob?: {
112
- id?: string;
113
- status?: string;
114
- };
115
- runtime?: {
116
- workerName?: string;
117
- hostname?: string;
118
- baseUrl?: string;
119
- syncUrl?: string;
120
- consoleUrl?: string;
121
- consoleServerUrl?: string;
122
- consoleToken?: string;
123
- publishableKey?: string;
124
- databaseProvider?: SpaceDbProvider;
125
- databaseName?: string;
126
- region?: string | null;
127
- };
128
- error?: string;
129
- message?: string;
130
- }
131
-
132
- const DEFAULT_FETCH_TIMEOUT_MS = 15_000;
133
- const CLI_SOURCE_LABEL = 'syncular-cli';
134
- const CONTRACT_WORKER_BUILDPACK_ID = 'contract-worker';
135
- const DEFAULT_VERIFY_CORS_ORIGIN = 'http://127.0.0.1:4320';
136
- const SPACE_DB_PROVIDERS = ['sqlite', 'neon', 'postgres'] as const;
137
- const SPACE_REGIONS = [
138
- 'auto',
139
- 'enam',
140
- 'wnam',
141
- 'weur',
142
- 'eeur',
143
- 'apac',
144
- 'oc',
145
- ] as const;
146
-
147
- const contractWorkerBuildpack = (() => {
148
- const buildpack = getBuildpackById(CONTRACT_WORKER_BUILDPACK_ID);
149
- if (!buildpack) {
150
- throw new Error(
151
- `Required buildpack "${CONTRACT_WORKER_BUILDPACK_ID}" is not registered.`
152
- );
153
- }
154
- return buildpack;
155
- })();
156
-
157
- function logStep(message: string): void {
158
- printInfo(`[${CLI_SOURCE_LABEL}] ${message}`);
159
- }
160
-
161
- async function fetchWithTimeout(
162
- url: string,
163
- init?: RequestInit,
164
- timeoutMs = DEFAULT_FETCH_TIMEOUT_MS
165
- ): Promise<Response> {
166
- try {
167
- return await fetch(url, {
168
- ...init,
169
- signal: AbortSignal.timeout(timeoutMs),
170
- });
171
- } catch (error: unknown) {
172
- if (error instanceof Error && error.name === 'TimeoutError') {
173
- throw new Error(`Request timed out after ${timeoutMs}ms: ${url}`);
174
- }
175
- throw error;
176
- }
177
- }
178
-
179
- function requiredFlag(
180
- flagValues: Map<string, string>,
181
- flag: string,
182
- envName?: string
183
- ): string {
184
- const value =
185
- flagValues.get(flag)?.trim() ||
186
- (envName ? process.env[envName]?.trim() : null);
187
- if (!value) {
188
- throw new Error(
189
- `Missing required ${flag}${envName ? ` (or ${envName})` : ''}`
190
- );
191
- }
192
- return value;
193
- }
194
-
195
- async function resolveControlTokenForCommand(args: {
196
- flagValues: Map<string, string>;
197
- controlPlaneBase: string;
198
- }): Promise<string> {
199
- return (
200
- (await resolveControlPlaneToken({
201
- flagValues: args.flagValues,
202
- controlPlaneBase: args.controlPlaneBase,
203
- includeStdinToken: false,
204
- includeStoredToken: true,
205
- })) || ''
206
- );
207
- }
208
-
209
- function buildControlPlaneHeaders(args: {
210
- actorId: string;
211
- controlToken: string;
212
- }): Record<string, string> {
213
- const token = parseBearerToken(args.controlToken);
214
- if (token.length > 0) {
215
- return {
216
- Authorization: `Bearer ${token}`,
217
- 'Content-Type': 'application/json',
218
- };
219
- }
220
- return {
221
- 'x-user-id': args.actorId,
222
- 'Content-Type': 'application/json',
223
- };
224
- }
225
-
226
- function resolveDefaultSpaceContractPath(): string {
227
- return resolveDefaultSyncularConfigPath(process.cwd());
228
- }
229
-
230
- function resolveSyncularVersion(): string {
231
- try {
232
- const repoRoot = resolveRepoRoot();
233
- const syncularPackageJsonPath = resolve(
234
- repoRoot,
235
- '..',
236
- 'syncular',
237
- 'package.json'
238
- );
239
- const parsed = JSON.parse(
240
- readFileSync(syncularPackageJsonPath, 'utf8')
241
- ) as {
242
- version?: string;
243
- };
244
- if (typeof parsed.version === 'string' && parsed.version.length > 0) {
245
- return parsed.version;
246
- }
247
- } catch {
248
- // ignored - fallback below
249
- }
250
- return 'unknown';
251
- }
252
-
253
- function resolveSyncularCliVersion(): string {
254
- const explicitVersion =
255
- process.env.SYNCULAR_CLI_NPM_VERSION?.trim() ||
256
- process.env.npm_package_version?.trim() ||
257
- '';
258
- return explicitVersion.length > 0 ? explicitVersion : '0.0.0';
259
- }
260
-
261
- function resolveGitCommit(): string {
262
- try {
263
- const repoRoot = resolveRepoRoot();
264
- return execFileSync('git', ['rev-parse', 'HEAD'], {
265
- cwd: repoRoot,
266
- encoding: 'utf8',
267
- stdio: ['ignore', 'pipe', 'ignore'],
268
- }).trim();
269
- } catch {
270
- return 'unknown';
271
- }
272
- }
273
-
274
- function buildImmutableScriptName(spaceId: string): string {
275
- const suffix = `${Date.now().toString(36)}-${randomUUID().slice(0, 6)}`;
276
- return `space-${spaceId}-${suffix}`;
277
- }
278
-
279
- async function waitForSuccessfulFetch(args: {
280
- url: string;
281
- init?: RequestInit;
282
- label: string;
283
- attempts?: number;
284
- delayMs?: number;
285
- timeoutMs?: number;
286
- }): Promise<Response> {
287
- const attempts = args.attempts ?? 12;
288
- const delayMs = args.delayMs ?? 1000;
289
- const timeoutMs = args.timeoutMs ?? 8_000;
290
-
291
- let lastResponse: Response | null = null;
292
- let lastError: Error | null = null;
293
- for (let attempt = 1; attempt <= attempts; attempt += 1) {
294
- try {
295
- const response = await fetchWithTimeout(args.url, args.init, timeoutMs);
296
- lastResponse = response;
297
- if (response.ok) {
298
- return response;
299
- }
300
- } catch (error: unknown) {
301
- lastError =
302
- error instanceof Error ? error : new Error('Unknown request error');
303
- }
304
-
305
- if (attempt < attempts) {
306
- const reason = lastResponse
307
- ? `${lastResponse.status} ${lastResponse.statusText}`
308
- : (lastError?.message ?? 'unknown error');
309
- logStep(
310
- `${args.label} attempt ${attempt}/${attempts} failed (${reason}), retrying in ${delayMs}ms`
311
- );
312
- await Bun.sleep(delayMs);
313
- }
314
- }
315
-
316
- throw new Error(
317
- `${args.label} failed (${lastResponse ? `${lastResponse.status} ${lastResponse.statusText}` : (lastError?.message ?? 'unknown error')})`
318
- );
319
- }
320
-
321
- function hasCaseInsensitiveToken(value: string | null, token: string): boolean {
322
- if (!value) {
323
- return false;
324
- }
325
- const normalizedToken = token.trim().toLowerCase();
326
- return value
327
- .split(',')
328
- .map((entry) => entry.trim().toLowerCase())
329
- .includes(normalizedToken);
330
- }
331
-
332
- async function verifyRuntimeSyncCorsPreflight(args: {
333
- baseUrl: string;
334
- origin: string;
335
- }): Promise<void> {
336
- const targetUrl = `${args.baseUrl.replace(/\/$/, '')}/api/sync`;
337
- const response = await fetchWithTimeout(
338
- targetUrl,
339
- {
340
- method: 'OPTIONS',
341
- headers: {
342
- Origin: args.origin,
343
- 'Access-Control-Request-Method': 'POST',
344
- 'Access-Control-Request-Headers':
345
- 'authorization,content-type,x-syncular-publishable-key',
346
- },
347
- },
348
- 10_000
349
- );
350
-
351
- if (!response.ok) {
352
- let details = `${response.status} ${response.statusText}`;
353
- try {
354
- const payload = (await response.clone().json()) as {
355
- error?: string;
356
- message?: string;
357
- };
358
- details = payload.message || payload.error || details;
359
- } catch {
360
- // ignored
361
- }
362
- throw new Error(
363
- `Runtime CORS preflight failed for ${args.origin}: ${details}`
364
- );
365
- }
366
-
367
- const allowOrigin = response.headers.get('access-control-allow-origin');
368
- if (allowOrigin !== '*' && allowOrigin !== args.origin) {
369
- throw new Error(
370
- `Runtime CORS origin mismatch for ${args.origin}. Received Access-Control-Allow-Origin=${allowOrigin ?? 'null'}.`
371
- );
372
- }
373
-
374
- if (
375
- !hasCaseInsensitiveToken(
376
- response.headers.get('access-control-allow-methods'),
377
- 'POST'
378
- )
379
- ) {
380
- throw new Error('Runtime CORS preflight missing POST in allow-methods.');
381
- }
382
-
383
- const requiredHeaders = [
384
- 'authorization',
385
- 'content-type',
386
- 'x-syncular-publishable-key',
387
- ];
388
- for (const requiredHeader of requiredHeaders) {
389
- if (
390
- !hasCaseInsensitiveToken(
391
- response.headers.get('access-control-allow-headers'),
392
- requiredHeader
393
- )
394
- ) {
395
- throw new Error(
396
- `Runtime CORS preflight missing ${requiredHeader} in allow-headers.`
397
- );
398
- }
399
- }
400
- }
401
-
402
- async function getSpaceRuntime(args: {
403
- controlPlaneBase: string;
404
- actorId: string;
405
- controlToken: string;
406
- spaceId: string;
407
- }): Promise<RuntimeInfo> {
408
- const response = await fetchWithTimeout(
409
- `${args.controlPlaneBase.replace(/\/$/, '')}/spaces/${args.spaceId}/runtime`,
410
- {
411
- method: 'GET',
412
- headers: buildControlPlaneHeaders(args),
413
- },
414
- 10_000
415
- );
416
-
417
- const payload = (await response.json()) as RuntimeResponse;
418
- if (
419
- !response.ok ||
420
- !payload.runtime?.workerName ||
421
- !payload.runtime.baseUrl
422
- ) {
423
- throw new Error(
424
- `Failed to load runtime for ${args.spaceId} (${response.status}): ${JSON.stringify(payload)}`
425
- );
426
- }
427
-
428
- const runtime = payload.runtime;
429
- const workerName = runtime?.workerName;
430
- const baseUrl = runtime?.baseUrl;
431
- const hostname = runtime?.hostname;
432
- const syncUrl = runtime?.syncUrl;
433
- const consoleToken = runtime?.consoleToken?.trim() || null;
434
- const databaseProvider =
435
- runtime?.databaseProvider === 'neon' ||
436
- runtime?.databaseProvider === 'postgres'
437
- ? runtime.databaseProvider
438
- : 'sqlite';
439
-
440
- if (!workerName || !baseUrl || !hostname || !syncUrl) {
441
- throw new Error(
442
- `Runtime response for ${args.spaceId} is incomplete: ${JSON.stringify(payload)}`
443
- );
444
- }
445
-
446
- return {
447
- workerName,
448
- hostname,
449
- baseUrl,
450
- syncUrl,
451
- consoleToken,
452
- databaseProvider,
453
- };
454
- }
455
-
456
- async function createDeployArtifactRecord(args: {
457
- controlPlaneBase: string;
458
- actorId: string;
459
- controlToken: string;
460
- spaceId: string;
461
- scriptName: string;
462
- artifactHash: string;
463
- source: string;
464
- sourceLabel: string;
465
- manifest: Record<string, unknown>;
466
- }): Promise<string> {
467
- const response = await fetchWithTimeout(
468
- `${args.controlPlaneBase.replace(/\/$/, '')}/spaces/${args.spaceId}/deploy-artifacts`,
469
- {
470
- method: 'POST',
471
- headers: buildControlPlaneHeaders(args),
472
- body: JSON.stringify({
473
- scriptName: args.scriptName,
474
- artifactHash: args.artifactHash,
475
- source: args.source,
476
- sourceLabel: args.sourceLabel,
477
- manifest: args.manifest,
478
- }),
479
- },
480
- 30_000
481
- );
482
-
483
- const payload = (await response.json()) as DeployArtifactResponse;
484
- if (!response.ok || !payload.artifact?.id) {
485
- throw new Error(
486
- `Failed to create deploy artifact (${response.status}): ${JSON.stringify(payload)}`
487
- );
488
- }
489
-
490
- return payload.artifact.id;
491
- }
492
-
493
- async function createDeployJobRecord(args: {
494
- controlPlaneBase: string;
495
- actorId: string;
496
- controlToken: string;
497
- spaceId: string;
498
- artifactId: string;
499
- scriptName: string;
500
- }): Promise<NonNullable<DeployJobResponse['job']>> {
501
- const response = await fetchWithTimeout(
502
- `${args.controlPlaneBase.replace(/\/$/, '')}/spaces/${args.spaceId}/deploy-jobs`,
503
- {
504
- method: 'POST',
505
- headers: buildControlPlaneHeaders(args),
506
- body: JSON.stringify({
507
- artifactId: args.artifactId,
508
- scriptName: args.scriptName,
509
- }),
510
- },
511
- 20_000
512
- );
513
-
514
- const payload = (await response.json()) as DeployJobResponse;
515
- if (!response.ok || !payload.job?.id) {
516
- throw new Error(
517
- `Failed to create deploy job (${response.status}): ${JSON.stringify(payload)}`
518
- );
519
- }
520
-
521
- return payload.job;
522
- }
523
-
524
- async function getDeployJob(args: {
525
- controlPlaneBase: string;
526
- actorId: string;
527
- controlToken: string;
528
- spaceId: string;
529
- jobId: string;
530
- }): Promise<NonNullable<DeployJobResponse['job']>> {
531
- const response = await fetchWithTimeout(
532
- `${args.controlPlaneBase.replace(/\/$/, '')}/spaces/${args.spaceId}/deploy-jobs/${args.jobId}`,
533
- {
534
- method: 'GET',
535
- headers: buildControlPlaneHeaders(args),
536
- },
537
- 10_000
538
- );
539
-
540
- const payload = (await response.json()) as DeployJobResponse;
541
- if (!response.ok || !payload.job?.id) {
542
- throw new Error(
543
- `Failed to load deploy job ${args.jobId} (${response.status}): ${JSON.stringify(payload)}`
544
- );
545
- }
546
-
547
- return payload.job;
548
- }
549
-
550
- async function waitForDeployJob(args: {
551
- controlPlaneBase: string;
552
- actorId: string;
553
- controlToken: string;
554
- spaceId: string;
555
- jobId: string;
556
- timeoutMs?: number;
557
- pollMs?: number;
558
- }): Promise<NonNullable<DeployJobResponse['job']>> {
559
- const timeoutMs = args.timeoutMs ?? 180_000;
560
- const pollMs = args.pollMs ?? 1_000;
561
- const startedAt = Date.now();
562
-
563
- let lastStatus: string | undefined;
564
- while (Date.now() - startedAt < timeoutMs) {
565
- const job = await getDeployJob(args);
566
- if (job.status && job.status !== lastStatus) {
567
- logStep(`Deploy job ${job.id} -> ${job.status}`);
568
- lastStatus = job.status;
569
- }
570
-
571
- if (job.status === 'completed' || job.status === 'failed') {
572
- return job;
573
- }
574
-
575
- await Bun.sleep(pollMs);
576
- }
577
-
578
- throw new Error(`Timed out waiting for deploy job ${args.jobId}.`);
579
- }
580
-
581
- async function listDeploymentRecords(args: {
582
- controlPlaneBase: string;
583
- actorId: string;
584
- controlToken: string;
585
- spaceId: string;
586
- limit: number;
587
- }): Promise<DeploymentsResponse> {
588
- const url = new URL(
589
- `${args.controlPlaneBase.replace(/\/$/, '')}/spaces/${args.spaceId}/deployments`
590
- );
591
- url.searchParams.set('limit', String(args.limit));
592
-
593
- const response = await fetchWithTimeout(
594
- url.toString(),
595
- {
596
- method: 'GET',
597
- headers: buildControlPlaneHeaders(args),
598
- },
599
- 10_000
600
- );
601
-
602
- const payload = (await response.json()) as DeploymentsResponse;
603
- if (!response.ok) {
604
- throw new Error(
605
- `Failed to list deployment records (${response.status}): ${JSON.stringify(payload)}`
606
- );
607
- }
608
-
609
- return payload;
610
- }
611
-
612
- async function rollbackDeploymentRecord(args: {
613
- controlPlaneBase: string;
614
- actorId: string;
615
- controlToken: string;
616
- spaceId: string;
617
- deploymentId: string;
618
- reason?: string;
619
- }): Promise<DeploymentResponse['deployment']> {
620
- const response = await fetchWithTimeout(
621
- `${args.controlPlaneBase.replace(/\/$/, '')}/spaces/${args.spaceId}/deployments/${args.deploymentId}/rollback`,
622
- {
623
- method: 'POST',
624
- headers: buildControlPlaneHeaders(args),
625
- body: JSON.stringify(args.reason ? { reason: args.reason } : {}),
626
- },
627
- 20_000
628
- );
629
-
630
- const payload = (await response.json()) as DeploymentResponse;
631
- if (!response.ok) {
632
- throw new Error(
633
- `Failed to rollback deployment (${response.status}): ${JSON.stringify(payload)}`
634
- );
635
- }
636
-
637
- return payload.deployment;
638
- }
639
-
640
- function redactConsoleUrl(url: string): string {
641
- try {
642
- const parsed = new URL(url);
643
- if (parsed.searchParams.has('token')) {
644
- parsed.searchParams.set('token', 'REDACTED');
645
- }
646
- return parsed.toString();
647
- } catch {
648
- return url.replace(/([?&]token=)[^&]+/gi, '$1REDACTED');
649
- }
650
- }
651
-
652
- function redactCreateSpaceValue(value: unknown): unknown {
653
- if (Array.isArray(value)) {
654
- return value.map((entry) => redactCreateSpaceValue(entry));
655
- }
656
-
657
- if (value && typeof value === 'object') {
658
- const redacted: Record<string, unknown> = {};
659
- for (const [key, nestedValue] of Object.entries(value)) {
660
- if (key === 'consoleToken') {
661
- continue;
662
- }
663
- if (key === 'consoleUrl' && typeof nestedValue === 'string') {
664
- redacted[key] = redactConsoleUrl(nestedValue);
665
- continue;
666
- }
667
- redacted[key] = redactCreateSpaceValue(nestedValue);
668
- }
669
- return redacted;
670
- }
671
-
672
- return value;
673
- }
674
-
675
- function redactCreateSpaceResponse(payload: CreateSpaceResponse): unknown {
676
- return redactCreateSpaceValue(payload);
677
- }
678
-
679
- async function parseCreateSpaceResponse(
680
- response: Response
681
- ): Promise<CreateSpaceResponse | null> {
682
- try {
683
- return (await response.json()) as CreateSpaceResponse;
684
- } catch {
685
- return null;
686
- }
687
- }
688
-
689
- export async function runCreateSpace(
690
- flagValues: Map<string, string>
691
- ): Promise<number> {
692
- try {
693
- const name = requiredFlag(flagValues, '--name');
694
- const databaseProvider = optionalEnumFlag(
695
- flagValues,
696
- '--database-provider',
697
- SPACE_DB_PROVIDERS,
698
- 'sqlite'
699
- );
700
- const region = optionalEnumFlag(
701
- flagValues,
702
- '--region',
703
- SPACE_REGIONS,
704
- 'auto'
705
- );
706
- const connectionString = flagValues.get('--connection-string')?.trim();
707
- if (
708
- (databaseProvider === 'neon' || databaseProvider === 'postgres') &&
709
- !connectionString
710
- ) {
711
- throw new Error(
712
- '--connection-string is required when --database-provider is neon or postgres.'
713
- );
714
- }
715
-
716
- const controlPlaneBase = resolveControlPlaneBase(flagValues);
717
- const actorId = optionalFlag(
718
- flagValues,
719
- '--actor-id',
720
- process.env.SPACES_TEST_ACTOR_ID?.trim() || CLI_SOURCE_LABEL
721
- );
722
- const controlToken = await resolveControlTokenForCommand({
723
- flagValues,
724
- controlPlaneBase,
725
- });
726
- const jsonOutput = optionalBooleanFlag(flagValues, '--json', false);
727
-
728
- const requestBody: {
729
- name: string;
730
- databaseProvider: SpaceDbProvider;
731
- region: SpaceRegion;
732
- connectionString?: string;
733
- } = {
734
- name,
735
- databaseProvider,
736
- region,
737
- };
738
- if (connectionString && connectionString.length > 0) {
739
- requestBody.connectionString = connectionString;
740
- }
741
-
742
- const response = await fetchWithTimeout(
743
- `${controlPlaneBase.replace(/\/$/, '')}/spaces`,
744
- {
745
- method: 'POST',
746
- headers: buildControlPlaneHeaders({
747
- actorId,
748
- controlToken,
749
- }),
750
- body: JSON.stringify(requestBody),
751
- },
752
- 30_000
753
- );
754
-
755
- const payload = await parseCreateSpaceResponse(response);
756
- if (!response.ok) {
757
- throw new Error(
758
- `Failed to create space (${response.status}): ${payload?.message || payload?.error || response.statusText || 'unknown error'}`
759
- );
760
- }
761
-
762
- const spaceId = payload?.space?.id;
763
- const runtimeBaseUrl = payload?.runtime?.baseUrl;
764
- const runtimeSyncUrl = payload?.runtime?.syncUrl;
765
- if (!spaceId || !runtimeBaseUrl || !runtimeSyncUrl) {
766
- throw new Error(
767
- `Create-space response is incomplete: ${JSON.stringify(payload)}`
768
- );
769
- }
770
-
771
- if (jsonOutput) {
772
- console.log(JSON.stringify(redactCreateSpaceResponse(payload), null, 2));
773
- return 0;
774
- }
775
-
776
- console.log('Space created successfully.');
777
- console.log(`Space: ${spaceId}`);
778
- console.log(`Name: ${payload.space?.name ?? name}`);
779
- console.log(`Runtime URL: ${runtimeBaseUrl}`);
780
- console.log(`Sync URL: ${runtimeSyncUrl}`);
781
- if (payload.runtime?.consoleUrl) {
782
- console.log(
783
- `Console URL: ${redactConsoleUrl(payload.runtime.consoleUrl)}`
784
- );
785
- }
786
- console.log(
787
- `Database provider: ${payload.runtime?.databaseProvider ?? databaseProvider}`
788
- );
789
- if (payload.runtime?.region) {
790
- console.log(`Region: ${payload.runtime.region}`);
791
- }
792
- if (payload.provisionJob?.id) {
793
- console.log(`Provision job: ${payload.provisionJob.id}`);
794
- }
795
- return 0;
796
- } catch (error: unknown) {
797
- printError(
798
- error instanceof Error
799
- ? error.message
800
- : 'Create-space failed unexpectedly.'
801
- );
802
- return 1;
803
- }
804
- }
805
-
806
- async function runDeployImmutable(
807
- flagValues: Map<string, string>
808
- ): Promise<number> {
809
- try {
810
- const spaceId = requiredFlag(flagValues, '--space');
811
- const controlPlaneBase = resolveControlPlaneBase(flagValues);
812
- const actorId = optionalFlag(
813
- flagValues,
814
- '--actor-id',
815
- process.env.SPACES_TEST_ACTOR_ID?.trim() || CLI_SOURCE_LABEL
816
- );
817
- const controlToken = await resolveControlTokenForCommand({
818
- flagValues,
819
- controlPlaneBase,
820
- });
821
- const sourceLabel = optionalFlag(flagValues, '--source', CLI_SOURCE_LABEL);
822
- const dryRun = optionalBooleanFlag(flagValues, '--dry-run', false);
823
- const explicitEntry = flagValues.get('--entry')?.trim() || null;
824
- const configPath = optionalFlag(
825
- flagValues,
826
- '--config',
827
- resolveDefaultSpaceContractPath()
828
- );
829
- if (!explicitEntry && !existsSync(configPath)) {
830
- throw new Error(
831
- `Syncular config not found: ${configPath}. Provide --config or --entry.`
832
- );
833
- }
834
-
835
- const scriptName = optionalFlag(
836
- flagValues,
837
- '--script-name',
838
- buildImmutableScriptName(spaceId)
839
- );
840
-
841
- let runtime: RuntimeInfo | null = null;
842
- if (!dryRun) {
843
- logStep(`Loading runtime for space ${spaceId}`);
844
- runtime = await getSpaceRuntime({
845
- controlPlaneBase,
846
- actorId,
847
- controlToken,
848
- spaceId,
849
- });
850
- }
851
-
852
- const bundleLabel = explicitEntry
853
- ? `entry ${explicitEntry}`
854
- : `config ${configPath}`;
855
- logStep(`Bundling space contract module from ${bundleLabel}`);
856
- const migrationMetadata = await contractWorkerBuildpack.inspect({
857
- configPath,
858
- explicitEntry,
859
- });
860
- const deploymentMetadata = {
861
- deployedAt: new Date().toISOString(),
862
- syncularVersion: resolveSyncularVersion(),
863
- syncularCliVersion: resolveSyncularCliVersion(),
864
- gitCommit: resolveGitCommit(),
865
- spaceId,
866
- workerName: scriptName,
867
- source: sourceLabel,
868
- schemaVersion: migrationMetadata.schemaVersion,
869
- migrationDigest: migrationMetadata.migrationDigest,
870
- migrations: {
871
- schemaVersion: migrationMetadata.schemaVersion,
872
- migrationDigest: migrationMetadata.migrationDigest,
873
- count: migrationMetadata.migrationCount,
874
- },
875
- ...(explicitEntry ? { entry: explicitEntry } : { configPath }),
876
- };
877
- const { source, artifactHash } = await contractWorkerBuildpack.build({
878
- configPath,
879
- explicitEntry,
880
- commentTag: 'syncular-spaces-deploy',
881
- metadata: deploymentMetadata,
882
- });
883
-
884
- if (dryRun) {
885
- logStep('Dry run complete (no Cloudflare deployment performed)');
886
- console.log(`Space: ${spaceId}`);
887
- console.log(`Worker: ${scriptName}`);
888
- if (explicitEntry) {
889
- console.log(`Entry: ${explicitEntry}`);
890
- } else {
891
- console.log(`Config: ${configPath}`);
892
- }
893
- console.log(`Artifact hash: ${artifactHash}`);
894
- console.log(`Deploy metadata: ${JSON.stringify(deploymentMetadata)}`);
895
- return 0;
896
- }
897
- logStep('Uploading artifact to control-plane');
898
- const artifactId = await createDeployArtifactRecord({
899
- controlPlaneBase,
900
- actorId,
901
- controlToken,
902
- spaceId,
903
- scriptName,
904
- artifactHash,
905
- source,
906
- sourceLabel,
907
- manifest: deploymentMetadata,
908
- });
909
-
910
- logStep('Creating deploy job in control-plane');
911
- const job = await createDeployJobRecord({
912
- controlPlaneBase,
913
- actorId,
914
- controlToken,
915
- spaceId,
916
- artifactId,
917
- scriptName,
918
- });
919
-
920
- const completedJob = await waitForDeployJob({
921
- controlPlaneBase,
922
- actorId,
923
- controlToken,
924
- spaceId,
925
- jobId: job.id!,
926
- });
927
- if (completedJob.status !== 'completed') {
928
- throw new Error(
929
- `Deploy job ${completedJob.id} failed: ${completedJob.errorText ?? 'unknown error'}`
930
- );
931
- }
932
- const deploymentId = completedJob.deploymentId ?? null;
933
-
934
- logStep('Validating health after activation');
935
- await waitForSuccessfulFetch({
936
- url: `${runtime!.baseUrl.replace(/\/$/, '')}/api/health`,
937
- label: 'Post-activation health check',
938
- });
939
-
940
- console.log('Runtime deployed successfully.');
941
- console.log(`Space: ${spaceId}`);
942
- console.log(`Worker: ${scriptName}`);
943
- console.log(`Base URL: ${runtime!.baseUrl}`);
944
- console.log(`Sync URL: ${runtime!.syncUrl}`);
945
- console.log(`Artifact hash: ${artifactHash}`);
946
- if (deploymentId) {
947
- console.log(`Deployment record: ${deploymentId}`);
948
- }
949
- console.log(`Deploy metadata: ${JSON.stringify(deploymentMetadata)}`);
950
- return 0;
951
- } catch (error: unknown) {
952
- printError(
953
- error instanceof Error ? error.message : 'Runtime deploy failed.'
954
- );
955
- return 1;
956
- }
957
- }
958
-
959
- export async function runDeploy(
960
- flagValues: Map<string, string>
961
- ): Promise<number> {
962
- try {
963
- await resolveEffectiveTargetId({
964
- cwd: process.cwd(),
965
- explicitTargetId: flagValues.get('--target')?.trim() || null,
966
- });
967
- return runDeployImmutable(flagValues);
968
- } catch (error: unknown) {
969
- printError(
970
- error instanceof Error ? error.message : 'Runtime deploy failed.'
971
- );
972
- return 1;
973
- }
974
- }
975
-
976
- async function runDeploymentsList(
977
- flagValues: Map<string, string>
978
- ): Promise<number> {
979
- try {
980
- const spaceId = requiredFlag(flagValues, '--space');
981
- const controlPlaneBase = resolveControlPlaneBase(flagValues);
982
- const actorId = optionalFlag(
983
- flagValues,
984
- '--actor-id',
985
- process.env.SPACES_TEST_ACTOR_ID?.trim() || CLI_SOURCE_LABEL
986
- );
987
- const controlToken = await resolveControlTokenForCommand({
988
- flagValues,
989
- controlPlaneBase,
990
- });
991
- const limit = optionalIntegerFlag(flagValues, '--limit', 20);
992
-
993
- const payload = await listDeploymentRecords({
994
- controlPlaneBase,
995
- actorId,
996
- controlToken,
997
- spaceId,
998
- limit,
999
- });
1000
- const deployments = payload.deployments ?? [];
1001
-
1002
- if (deployments.length === 0) {
1003
- console.log(`No deployments found for space ${spaceId}.`);
1004
- return 0;
1005
- }
1006
-
1007
- console.log(`Deployments for ${spaceId}:`);
1008
- for (const deployment of deployments) {
1009
- const id = deployment.id ?? '<unknown>';
1010
- const status = deployment.status ?? 'unknown';
1011
- const scriptName = deployment.scriptName ?? '<unknown-script>';
1012
- const source = deployment.source ?? 'unknown';
1013
- const createdAt = deployment.createdAt ?? 'unknown-time';
1014
- const artifactHash = deployment.artifactHash ?? '-';
1015
- const rollbackOf = deployment.rollbackOfDeploymentId ?? '-';
1016
- console.log(
1017
- `${id} | ${status} | ${scriptName} | source=${source} | created=${createdAt} | hash=${artifactHash} | rollbackOf=${rollbackOf}`
1018
- );
1019
- }
1020
- return 0;
1021
- } catch (error: unknown) {
1022
- printError(
1023
- error instanceof Error
1024
- ? error.message
1025
- : 'Failed to list deployments unexpectedly.'
1026
- );
1027
- return 1;
1028
- }
1029
- }
1030
-
1031
- export async function runDeployments(
1032
- flagValues: Map<string, string>
1033
- ): Promise<number> {
1034
- return runDeploymentsList(flagValues);
1035
- }
1036
-
1037
- export async function runRollback(
1038
- flagValues: Map<string, string>
1039
- ): Promise<number> {
1040
- try {
1041
- const spaceId = requiredFlag(flagValues, '--space');
1042
- const deploymentId = requiredFlag(flagValues, '--to');
1043
- const controlPlaneBase = resolveControlPlaneBase(flagValues);
1044
- const actorId = optionalFlag(
1045
- flagValues,
1046
- '--actor-id',
1047
- process.env.SPACES_TEST_ACTOR_ID?.trim() || CLI_SOURCE_LABEL
1048
- );
1049
- const controlToken = await resolveControlTokenForCommand({
1050
- flagValues,
1051
- controlPlaneBase,
1052
- });
1053
- const reason = flagValues.get('--reason')?.trim();
1054
-
1055
- const rollbackDeployment = await rollbackDeploymentRecord({
1056
- controlPlaneBase,
1057
- actorId,
1058
- controlToken,
1059
- spaceId,
1060
- deploymentId,
1061
- reason: reason && reason.length > 0 ? reason : undefined,
1062
- });
1063
-
1064
- console.log('Rollback completed.');
1065
- console.log(`Space: ${spaceId}`);
1066
- console.log(`Target deployment: ${deploymentId}`);
1067
- if (rollbackDeployment?.id) {
1068
- console.log(`New deployment: ${rollbackDeployment.id}`);
1069
- }
1070
- if (rollbackDeployment?.scriptName) {
1071
- console.log(`Script: ${rollbackDeployment.scriptName}`);
1072
- }
1073
- if (rollbackDeployment?.status) {
1074
- console.log(`Status: ${rollbackDeployment.status}`);
1075
- }
1076
- return 0;
1077
- } catch (error: unknown) {
1078
- printError(
1079
- error instanceof Error ? error.message : 'Rollback failed unexpectedly.'
1080
- );
1081
- return 1;
1082
- }
1083
- }
1084
-
1085
- async function runVerifySpaces(
1086
- flagValues: Map<string, string>
1087
- ): Promise<number> {
1088
- try {
1089
- const spaceId = requiredFlag(flagValues, '--space');
1090
- const controlPlaneBase = resolveControlPlaneBase(flagValues);
1091
- const actorId = optionalFlag(
1092
- flagValues,
1093
- '--actor-id',
1094
- process.env.SPACES_TEST_ACTOR_ID?.trim() || CLI_SOURCE_LABEL
1095
- );
1096
- const controlToken = await resolveControlTokenForCommand({
1097
- flagValues,
1098
- controlPlaneBase,
1099
- });
1100
- const runtime = await getSpaceRuntime({
1101
- controlPlaneBase,
1102
- actorId,
1103
- controlToken,
1104
- spaceId,
1105
- });
1106
- const baseUrl = optionalFlag(flagValues, '--base-url', runtime.baseUrl);
1107
- const corsOrigin = optionalFlag(
1108
- flagValues,
1109
- '--origin',
1110
- DEFAULT_VERIFY_CORS_ORIGIN
1111
- );
1112
-
1113
- await waitForSuccessfulFetch({
1114
- url: `${baseUrl.replace(/\/$/, '')}/api/health`,
1115
- label: 'Runtime health check',
1116
- });
1117
- await verifyRuntimeSyncCorsPreflight({
1118
- baseUrl,
1119
- origin: corsOrigin,
1120
- });
1121
-
1122
- console.log('Runtime verify passed.');
1123
- console.log(`Space: ${spaceId}`);
1124
- console.log(`Runtime URL: ${baseUrl}`);
1125
- console.log(`Sync URL: ${runtime.syncUrl}`);
1126
- console.log(`CORS origin check: ${corsOrigin}`);
1127
- return 0;
1128
- } catch (error: unknown) {
1129
- printError(
1130
- error instanceof Error ? error.message : 'Runtime verify failed.'
1131
- );
1132
- return 1;
1133
- }
1134
- }
1135
-
1136
- export async function runVerify(
1137
- flagValues: Map<string, string>
1138
- ): Promise<number> {
1139
- try {
1140
- await resolveEffectiveTargetId({
1141
- cwd: process.cwd(),
1142
- explicitTargetId: flagValues.get('--target')?.trim() || null,
1143
- });
1144
- return runVerifySpaces(flagValues);
1145
- } catch (error: unknown) {
1146
- printError(
1147
- error instanceof Error ? error.message : 'Runtime verify failed.'
1148
- );
1149
- return 1;
1150
- }
1151
- }