@syncular/cli 0.0.0-44 → 0.0.0-46

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