@git.zone/tsdeploy 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,10 @@
1
1
  import * as plugins from './plugins.js';
2
2
  import type {
3
+ ITsDeployCommandRunOptions,
4
+ ITsDeployCommandRunResult,
5
+ ITsDeployDeployOptions,
6
+ ITsDeployDeployResult,
7
+ ITsDeployDeploymentSummary,
3
8
  ITsDeployDoctorResult,
4
9
  ITsDeployLinkOptions,
5
10
  ITsDeployPlan,
@@ -12,14 +17,37 @@ import type {
12
17
  TTsDeployTarget,
13
18
  } from './types.js';
14
19
 
20
+ type TTypedRequestShape = {
21
+ method: string;
22
+ request: unknown;
23
+ response: unknown;
24
+ };
25
+
26
+ type TCloudlyRequest = <TRequest extends TTypedRequestShape>(
27
+ originArg: string,
28
+ methodArg: TRequest['method'],
29
+ requestArg: TRequest['request'],
30
+ ) => Promise<TRequest['response']>;
31
+
32
+ type TCommandRunner = (optionsArg: ITsDeployCommandRunOptions) => Promise<ITsDeployCommandRunResult>;
33
+
15
34
  export interface ITsDeployOptions {
16
35
  projectDir?: string;
17
36
  profileStorePath?: string;
18
37
  ephemeralProfileStore?: boolean;
38
+ env?: Record<string, string | undefined>;
39
+ commandRunner?: TCommandRunner;
40
+ cloudlyRequest?: TCloudlyRequest;
19
41
  }
20
42
 
21
43
  const schemaVersion = 1 as const;
22
44
  const profileNameRegex = /^[a-zA-Z0-9._-]+$/;
45
+ const defaultPlatform = 'linux/amd64';
46
+ const defaultCloudlyRequestTimeoutMs = 30000;
47
+ const defaultCommandTimeoutMs = 30 * 60 * 1000;
48
+ const dockerCommandTimeoutMs = 60 * 60 * 1000;
49
+ const defaultMaxCommandOutputBytes = 1024 * 1024;
50
+ const defaultVerifyUrlTimeoutMs = 15000;
23
51
 
24
52
  const normalizeProfileName = (profileArg: string): string => {
25
53
  const profile = profileArg.trim();
@@ -53,10 +81,16 @@ export const redactProfile = (profileArg: ITsDeployProfile): ITsDeployPublicProf
53
81
  export class TsDeploy {
54
82
  public readonly projectDir: string;
55
83
  private readonly profileStore: plugins.smartconfig.KeyValueStore<ITsDeployUserState>;
84
+ private readonly env: Record<string, string | undefined>;
85
+ private readonly commandRunner: TCommandRunner;
86
+ private readonly cloudlyRequest: TCloudlyRequest;
56
87
 
57
88
  constructor(optionsArg: ITsDeployOptions = {}) {
58
89
  this.projectDir = plugins.path.resolve(optionsArg.projectDir ?? process.cwd());
59
90
  this.profileStore = this.createProfileStore(optionsArg);
91
+ this.env = optionsArg.env ?? process.env;
92
+ this.commandRunner = optionsArg.commandRunner ?? this.defaultCommandRunner;
93
+ this.cloudlyRequest = optionsArg.cloudlyRequest ?? this.defaultCloudlyRequest;
60
94
  }
61
95
 
62
96
  public get projectLinkPath(): string {
@@ -178,7 +212,7 @@ export class TsDeploy {
178
212
  warnings.push(`Profile "${activeProfile}" is not configured. Run tsdeploy login --profile ${activeProfile}.`);
179
213
  }
180
214
  if (profile && !profile.token) {
181
- warnings.push(`Profile "${profile.name}" has no token. Remote deploy mutations remain unavailable.`);
215
+ warnings.push(`Profile "${profile.name}" has no token. Cloudly deploy requires a token or username/password at deploy time.`);
182
216
  }
183
217
  return {
184
218
  projectDir: this.projectDir,
@@ -193,11 +227,19 @@ export class TsDeploy {
193
227
  public async buildPlan(): Promise<ITsDeployPlan> {
194
228
  const status = await this.getStatus();
195
229
  const actions: ITsDeployPlan['actions'] = [];
230
+ const canDeployCloudly = Boolean(status.projectLink?.serviceId && status.profile?.target === 'cloudly');
196
231
  if (status.projectLink && status.profile) {
197
232
  actions.push({
198
233
  type: 'inspect',
199
234
  title: 'Inspect linked deployment',
200
- description: 'Resolve the linked Cloudly/Onebox deployment and compare desired app metadata once typed deployment APIs are verified.',
235
+ description: 'Resolve the linked Cloudly service and registry target before deploying.',
236
+ });
237
+ actions.push({
238
+ type: canDeployCloudly ? 'deploy' : 'noop',
239
+ title: canDeployCloudly ? 'Deploy Cloudly workload image' : 'Deployment link incomplete or unsupported',
240
+ description: canDeployCloudly
241
+ ? 'Run project checks, build and push the linked OCI image, and let Cloudly deployOnPush reconcile the service.'
242
+ : 'Cloudly workload deploy requires a serviceId; Onebox deployment mutations are not implemented yet.',
201
243
  });
202
244
  } else {
203
245
  actions.push({
@@ -206,16 +248,13 @@ export class TsDeploy {
206
248
  description: 'Create a profile and project link before a deployment plan can inspect remote state.',
207
249
  });
208
250
  }
209
- actions.push({
210
- type: 'noop',
211
- title: 'Deployment mutation disabled',
212
- description: 'tsdeploy MVP does not install, update, or upgrade apps until Cloudly/Onebox deployment contracts are verified upstream.',
213
- });
214
251
  return {
215
252
  schemaVersion,
216
253
  projectDir: this.projectDir,
217
- mutationAllowed: false,
218
- summary: 'Safe planning only: no remote deployment mutations will be executed.',
254
+ mutationAllowed: canDeployCloudly,
255
+ summary: canDeployCloudly
256
+ ? 'Cloudly workload deploy is available through registry push and deployOnPush reconciliation.'
257
+ : 'Safe planning only: no remote deployment mutations will be executed.',
219
258
  profile: status.activeProfile,
220
259
  deploymentId: status.projectLink?.deploymentId,
221
260
  serviceId: status.projectLink?.serviceId,
@@ -240,7 +279,9 @@ export class TsDeploy {
240
279
  {
241
280
  name: 'remote-mutations',
242
281
  ok: true,
243
- message: 'Remote deployment mutations are intentionally disabled in this MVP.',
282
+ message: status.profile?.target === 'cloudly'
283
+ ? 'Cloudly workload deployment is available with deploy-time Cloudly credentials.'
284
+ : 'Only Cloudly workload deployment is implemented currently.',
244
285
  },
245
286
  ];
246
287
  return {
@@ -249,4 +290,579 @@ export class TsDeploy {
249
290
  warnings: status.warnings,
250
291
  };
251
292
  }
293
+
294
+ public async deploy(optionsArg: ITsDeployDeployOptions = {}): Promise<ITsDeployDeployResult> {
295
+ const status = await this.getStatus();
296
+ const projectLink = status.projectLink;
297
+ const profile = await this.getProfile(projectLink?.profile ?? status.activeProfile);
298
+ const steps: ITsDeployDeployResult['steps'] = [];
299
+ const warnings: string[] = [];
300
+ const tag = optionsArg.tag || 'latest';
301
+ const platform = optionsArg.platform || defaultPlatform;
302
+ const waitSeconds = optionsArg.waitSeconds ?? 0;
303
+
304
+ if (!projectLink) {
305
+ throw new Error('No .nogit/tsdeploy.json project link found. Run tsdeploy link first.');
306
+ }
307
+ if (!projectLink.serviceId) {
308
+ throw new Error('Project link has no serviceId. Run tsdeploy link --serviceId <id>.');
309
+ }
310
+ if (!profile) {
311
+ throw new Error(`Profile "${projectLink.profile}" is not configured. Run tsdeploy login --profile ${projectLink.profile}.`);
312
+ }
313
+ if (profile.target !== 'cloudly') {
314
+ throw new Error(`Deploy target "${profile.target}" is not implemented. Only Cloudly workload deployment is supported.`);
315
+ }
316
+ const deploySecretValues = this.collectSecretValues(undefined, [
317
+ profile.token,
318
+ optionsArg.token,
319
+ optionsArg.password,
320
+ this.env.TSDEPLOY_TOKEN,
321
+ this.env.TSDEPLOY_CLOUDLY_PASSWORD,
322
+ this.env.CLOUDLY_PASSWORD,
323
+ ]);
324
+
325
+ const localConfig = await this.readProjectSmartconfig();
326
+ const localImage = this.resolveLocalDockerImage(localConfig, tag);
327
+ let image = optionsArg.image || localImage;
328
+
329
+ if (optionsArg.dryRun) {
330
+ if (!image) {
331
+ image = '<resolved-from-cloudly-service-registry-target>';
332
+ warnings.push('Dry run did not contact Cloudly, so the final image URL was not resolved.');
333
+ }
334
+ steps.push({ name: 'cloudly-preflight', status: 'skipped', message: 'Dry run skipped Cloudly service validation.' });
335
+ steps.push({ name: 'checks', status: 'skipped', message: 'Dry run skipped project checks.' });
336
+ steps.push({ name: 'docker-buildx-push', status: 'skipped', message: `Dry run would push ${image}.` });
337
+ return {
338
+ schemaVersion,
339
+ projectDir: this.projectDir,
340
+ profile: profile.name,
341
+ serviceId: projectLink.serviceId,
342
+ deploymentId: projectLink.deploymentId,
343
+ dryRun: true,
344
+ image,
345
+ tag,
346
+ platform,
347
+ pushed: false,
348
+ triggered: false,
349
+ verified: false,
350
+ deployments: [],
351
+ steps,
352
+ warnings,
353
+ };
354
+ }
355
+
356
+ const identity = await this.resolveCloudlyIdentity(profile, optionsArg);
357
+ steps.push({ name: 'cloudly-auth', status: 'passed', message: 'Cloudly identity resolved.' });
358
+
359
+ const service = await this.fireCloudlyRequest<plugins.servezoneInterfaces.requests.service.IRequest_Any_Cloudly_GetServiceById>(
360
+ profile.origin,
361
+ 'getServiceById',
362
+ { identity, serviceId: projectLink.serviceId },
363
+ ).then(response => response.service);
364
+ if (service.data.deployOnPush === false) {
365
+ throw new Error(`Service "${service.data.name}" has deployOnPush disabled; refusing to push an image that Cloudly will not deploy.`);
366
+ }
367
+ const registryTarget = await this.fireCloudlyRequest<plugins.servezoneInterfaces.requests.service.IRequest_Any_Cloudly_GetServiceRegistryTarget>(
368
+ profile.origin,
369
+ 'getServiceRegistryTarget',
370
+ { identity, serviceId: projectLink.serviceId, tag },
371
+ ).then(response => response.registryTarget);
372
+ if (!registryTarget.imageUrl) {
373
+ throw new Error('Cloudly did not return a registry target image URL for the linked service.');
374
+ }
375
+ if (optionsArg.image && optionsArg.image !== registryTarget.imageUrl) {
376
+ throw new Error(`Requested image "${optionsArg.image}" does not match Cloudly registry target "${registryTarget.imageUrl}".`);
377
+ }
378
+ image = registryTarget.imageUrl;
379
+ if (localImage && localImage !== image) {
380
+ warnings.push(`Local .smartconfig Docker image "${localImage}" differs from Cloudly registry target "${image}". Using Cloudly target.`);
381
+ }
382
+ steps.push({ name: 'cloudly-preflight', status: 'passed', message: `Validated service "${service.data.name}" and registry target.` });
383
+
384
+ if (!optionsArg.skipPush) {
385
+ await this.ensureCleanGitWorktree(steps, Boolean(optionsArg.allowDirty));
386
+ }
387
+
388
+ if (optionsArg.skipChecks) {
389
+ steps.push({ name: 'checks', status: 'skipped', message: 'Project checks skipped by option.' });
390
+ } else {
391
+ const checkCommand = await this.resolveProjectCheckCommand();
392
+ if (checkCommand) {
393
+ await this.runCheckedCommand({
394
+ command: checkCommand.command,
395
+ args: checkCommand.args,
396
+ cwd: this.projectDir,
397
+ env: this.env,
398
+ timeoutMs: defaultCommandTimeoutMs,
399
+ maxOutputBytes: defaultMaxCommandOutputBytes,
400
+ }, 'checks', steps, deploySecretValues);
401
+ } else {
402
+ steps.push({ name: 'checks', status: 'skipped', message: 'No test or build script found in package.json.' });
403
+ }
404
+ }
405
+
406
+ let digest: string | undefined;
407
+ if (optionsArg.skipPush) {
408
+ steps.push({ name: 'docker-buildx-push', status: 'skipped', message: 'Docker push skipped by option.' });
409
+ } else {
410
+ const buildEnv = await this.resolveDockerBuildEnv(localConfig);
411
+ const buildArgs = Object.keys(buildEnv.buildArgValues);
412
+ const dockerArgs = [
413
+ 'buildx',
414
+ 'build',
415
+ ...(optionsArg.builder ? ['--builder', optionsArg.builder] : []),
416
+ '--platform',
417
+ platform,
418
+ '--provenance=false',
419
+ '--sbom=false',
420
+ ...buildArgs.flatMap(buildArg => ['--build-arg', buildArg]),
421
+ '-t',
422
+ image,
423
+ '--push',
424
+ '.',
425
+ ];
426
+ const commandResult = await this.runCheckedCommand({
427
+ command: 'docker',
428
+ args: dockerArgs,
429
+ cwd: this.projectDir,
430
+ env: {
431
+ ...this.env,
432
+ ...buildEnv.buildArgValues,
433
+ },
434
+ timeoutMs: dockerCommandTimeoutMs,
435
+ maxOutputBytes: defaultMaxCommandOutputBytes,
436
+ }, 'docker-buildx-push', steps, [...deploySecretValues, ...buildEnv.secretValues]);
437
+ digest = this.extractDigest(`${commandResult.stdout}\n${commandResult.stderr}`);
438
+ }
439
+
440
+ let deployments = await this.getCloudlyDeployments(profile.origin, identity, projectLink.serviceId);
441
+ if (waitSeconds > 0) {
442
+ deployments = await this.waitForHealthyDeployment(profile.origin, identity, projectLink.serviceId, waitSeconds);
443
+ }
444
+
445
+ let verifyStatus: number | undefined;
446
+ if (optionsArg.verifyUrl) {
447
+ verifyStatus = await this.verifyUrl(optionsArg.verifyUrl);
448
+ steps.push({
449
+ name: 'verify-url',
450
+ status: verifyStatus >= 200 && verifyStatus < 400 ? 'passed' : 'failed',
451
+ message: `${optionsArg.verifyUrl} returned HTTP ${verifyStatus}.`,
452
+ });
453
+ if (verifyStatus < 200 || verifyStatus >= 400) {
454
+ throw new Error(`Verification URL returned HTTP ${verifyStatus}: ${optionsArg.verifyUrl}`);
455
+ }
456
+ }
457
+
458
+ return {
459
+ schemaVersion,
460
+ projectDir: this.projectDir,
461
+ profile: profile.name,
462
+ serviceId: projectLink.serviceId,
463
+ deploymentId: projectLink.deploymentId,
464
+ dryRun: false,
465
+ image,
466
+ tag,
467
+ platform,
468
+ pushed: !optionsArg.skipPush,
469
+ triggered: !optionsArg.skipPush,
470
+ verified: Boolean(optionsArg.verifyUrl && verifyStatus && verifyStatus >= 200 && verifyStatus < 400),
471
+ digest,
472
+ verifyStatus,
473
+ service: {
474
+ serviceId: service.id,
475
+ serviceName: service.data.name,
476
+ deployOnPush: true,
477
+ registryTarget: {
478
+ registryHost: registryTarget.registryHost,
479
+ repository: registryTarget.repository,
480
+ tag: registryTarget.tag,
481
+ imageUrl: registryTarget.imageUrl,
482
+ },
483
+ },
484
+ deployments,
485
+ steps,
486
+ warnings,
487
+ };
488
+ }
489
+
490
+ private defaultCommandRunner: TCommandRunner = async (optionsArg) => {
491
+ return await new Promise<ITsDeployCommandRunResult>((resolve, reject) => {
492
+ const timeoutMs = optionsArg.timeoutMs ?? defaultCommandTimeoutMs;
493
+ const maxOutputBytes = optionsArg.maxOutputBytes ?? defaultMaxCommandOutputBytes;
494
+ const child = plugins.childProcess.spawn(optionsArg.command, optionsArg.args, {
495
+ cwd: optionsArg.cwd,
496
+ env: {
497
+ ...process.env,
498
+ ...optionsArg.env,
499
+ },
500
+ detached: process.platform !== 'win32',
501
+ stdio: ['ignore', 'pipe', 'pipe'],
502
+ });
503
+ const stdoutChunks: Buffer[] = [];
504
+ const stderrChunks: Buffer[] = [];
505
+ let stdoutBytes = 0;
506
+ let stderrBytes = 0;
507
+ let stdoutTruncated = false;
508
+ let stderrTruncated = false;
509
+ let timedOut = false;
510
+ let timeoutRef: NodeJS.Timeout | undefined;
511
+ let killTimeoutRef: NodeJS.Timeout | undefined;
512
+ const appendOutput = (chunksArg: Buffer[], chunkArg: Buffer, bytesArg: number): { bytes: number; truncated: boolean } => {
513
+ const bytes = bytesArg + chunkArg.length;
514
+ const remainingBytes = maxOutputBytes - bytesArg;
515
+ if (remainingBytes > 0) {
516
+ chunksArg.push(chunkArg.subarray(0, remainingBytes));
517
+ }
518
+ return { bytes, truncated: bytes > maxOutputBytes };
519
+ };
520
+ child.stdout?.on('data', chunkArg => {
521
+ const result = appendOutput(stdoutChunks, Buffer.from(chunkArg), stdoutBytes);
522
+ stdoutBytes = result.bytes;
523
+ stdoutTruncated ||= result.truncated;
524
+ });
525
+ child.stderr?.on('data', chunkArg => {
526
+ const result = appendOutput(stderrChunks, Buffer.from(chunkArg), stderrBytes);
527
+ stderrBytes = result.bytes;
528
+ stderrTruncated ||= result.truncated;
529
+ });
530
+ const clearTimers = (clearKillTimerArg = true) => {
531
+ if (timeoutRef) clearTimeout(timeoutRef);
532
+ if (clearKillTimerArg && killTimeoutRef) clearTimeout(killTimeoutRef);
533
+ };
534
+ const killProcess = (signalArg: NodeJS.Signals) => {
535
+ if (process.platform !== 'win32' && child.pid) {
536
+ try {
537
+ process.kill(-child.pid, signalArg);
538
+ return;
539
+ } catch {
540
+ // Fall back to direct child termination below.
541
+ }
542
+ }
543
+ child.kill(signalArg);
544
+ };
545
+ timeoutRef = setTimeout(() => {
546
+ timedOut = true;
547
+ killProcess('SIGTERM');
548
+ killTimeoutRef = setTimeout(() => {
549
+ killProcess('SIGKILL');
550
+ }, 10000);
551
+ killTimeoutRef.unref?.();
552
+ }, timeoutMs);
553
+ timeoutRef.unref?.();
554
+ child.on('error', errorArg => {
555
+ clearTimers(!timedOut);
556
+ reject(errorArg);
557
+ });
558
+ child.on('close', exitCode => {
559
+ clearTimers(!timedOut);
560
+ if (stdoutTruncated) {
561
+ stdoutChunks.push(Buffer.from('\n[tsdeploy: stdout truncated]\n'));
562
+ }
563
+ if (stderrTruncated) {
564
+ stderrChunks.push(Buffer.from('\n[tsdeploy: stderr truncated]\n'));
565
+ }
566
+ if (timedOut) {
567
+ stderrChunks.push(Buffer.from(`\n[tsdeploy: command timed out after ${timeoutMs}ms]\n`));
568
+ }
569
+ resolve({
570
+ command: optionsArg.command,
571
+ args: optionsArg.args,
572
+ exitCode: timedOut ? 124 : exitCode ?? 1,
573
+ stdout: Buffer.concat(stdoutChunks).toString('utf8'),
574
+ stderr: Buffer.concat(stderrChunks).toString('utf8'),
575
+ });
576
+ });
577
+ });
578
+ };
579
+
580
+ private defaultCloudlyRequest: TCloudlyRequest = async <TRequest extends TTypedRequestShape>(originArg: string, methodArg: TRequest['method'], requestArg: TRequest['request']): Promise<TRequest['response']> => {
581
+ const controller = new AbortController();
582
+ const timeoutRef = setTimeout(() => controller.abort(), defaultCloudlyRequestTimeoutMs);
583
+ timeoutRef.unref?.();
584
+ try {
585
+ const response = await fetch(`${originArg.replace(/\/+$/, '')}/typedrequest`, {
586
+ method: 'POST',
587
+ headers: {
588
+ 'content-type': 'application/json',
589
+ },
590
+ body: JSON.stringify({
591
+ method: methodArg,
592
+ request: requestArg,
593
+ response: {},
594
+ correlation: {
595
+ id: plugins.crypto.randomUUID(),
596
+ phase: 'request',
597
+ },
598
+ }),
599
+ signal: controller.signal,
600
+ });
601
+ if (!response.ok) {
602
+ await response.body?.cancel().catch(() => undefined);
603
+ throw new Error(`Cloudly request "${methodArg}" failed with HTTP ${response.status}.`);
604
+ }
605
+ const payload = await response.json() as {
606
+ response: TRequest['response'];
607
+ error?: { text: string; data?: unknown };
608
+ retry?: { waitForMs: number; reason: string };
609
+ };
610
+ if (payload.error) {
611
+ throw new plugins.typedrequest.TypedResponseError(payload.error.text, payload.error.data);
612
+ }
613
+ if (payload.retry) {
614
+ throw new Error(`Cloudly request "${methodArg}" requested retry after ${payload.retry.waitForMs}ms: ${payload.retry.reason}`);
615
+ }
616
+ return payload.response;
617
+ } catch (error) {
618
+ if ((error as { name?: string }).name === 'AbortError') {
619
+ throw new Error(`Cloudly request "${methodArg}" timed out after ${defaultCloudlyRequestTimeoutMs}ms.`);
620
+ }
621
+ throw error;
622
+ } finally {
623
+ clearTimeout(timeoutRef);
624
+ }
625
+ };
626
+
627
+ private async fireCloudlyRequest<TRequest extends TTypedRequestShape>(
628
+ originArg: string,
629
+ methodArg: TRequest['method'],
630
+ requestArg: TRequest['request'],
631
+ ): Promise<TRequest['response']> {
632
+ return await this.cloudlyRequest<TRequest>(originArg, methodArg, requestArg);
633
+ }
634
+
635
+ private async resolveCloudlyIdentity(
636
+ profileArg: ITsDeployProfile,
637
+ optionsArg: ITsDeployDeployOptions,
638
+ ): Promise<plugins.servezoneInterfaces.data.IIdentity> {
639
+ const username = optionsArg.username || this.env.TSDEPLOY_CLOUDLY_USERNAME || this.env.CLOUDLY_USERNAME;
640
+ const password = optionsArg.password || this.env.TSDEPLOY_CLOUDLY_PASSWORD || this.env.CLOUDLY_PASSWORD;
641
+ if (username && password) {
642
+ const response = await this.fireCloudlyRequest<plugins.servezoneInterfaces.requests.admin.IReq_Admin_LoginWithUsernameAndPassword>(
643
+ profileArg.origin,
644
+ 'adminLoginWithUsernameAndPassword',
645
+ { username, password },
646
+ );
647
+ return response.identity;
648
+ }
649
+ const token = optionsArg.token || this.env.TSDEPLOY_TOKEN || profileArg.token;
650
+ if (token) {
651
+ const response = await this.fireCloudlyRequest<plugins.servezoneInterfaces.requests.identity.IRequest_Any_Cloudly_CoreflowManager_GetIdentityByToken>(
652
+ profileArg.origin,
653
+ 'getIdentityByToken',
654
+ { token },
655
+ );
656
+ return response.identity;
657
+ }
658
+ throw new Error('Cloudly credentials are required for deploy. Provide --token, TSDEPLOY_TOKEN, profile token, or TSDEPLOY_CLOUDLY_USERNAME/TSDEPLOY_CLOUDLY_PASSWORD.');
659
+ }
660
+
661
+ private async readProjectSmartconfig(): Promise<Record<string, any>> {
662
+ try {
663
+ const raw = await plugins.fs.readFile(plugins.path.join(this.projectDir, '.smartconfig.json'), 'utf8');
664
+ return JSON.parse(raw) as Record<string, any>;
665
+ } catch (error) {
666
+ if ((error as { code?: string }).code === 'ENOENT') return {};
667
+ throw error;
668
+ }
669
+ }
670
+
671
+ private async readProjectPackageJson(): Promise<Record<string, any>> {
672
+ try {
673
+ const raw = await plugins.fs.readFile(plugins.path.join(this.projectDir, 'package.json'), 'utf8');
674
+ return JSON.parse(raw) as Record<string, any>;
675
+ } catch (error) {
676
+ if ((error as { code?: string }).code === 'ENOENT') return {};
677
+ throw error;
678
+ }
679
+ }
680
+
681
+ private resolveLocalDockerImage(configArg: Record<string, any>, tagArg: string): string | undefined {
682
+ const dockerConfig = configArg['@git.zone/tsdocker'];
683
+ const registries = Array.isArray(dockerConfig?.registries) ? dockerConfig.registries : [];
684
+ const registryRepoMap = dockerConfig?.registryRepoMap ?? {};
685
+ const registryHost = registries[0] ?? Object.keys(registryRepoMap)[0];
686
+ const repository = registryHost ? registryRepoMap[registryHost] : undefined;
687
+ if (!registryHost || !repository) return undefined;
688
+ return `${registryHost}/${repository}:${tagArg}`;
689
+ }
690
+
691
+ private async resolveProjectCheckCommand(): Promise<{ command: string; args: string[] } | undefined> {
692
+ const packageJson = await this.readProjectPackageJson();
693
+ if (packageJson.scripts?.test) {
694
+ return { command: 'pnpm', args: ['test'] };
695
+ }
696
+ if (packageJson.scripts?.build) {
697
+ return { command: 'pnpm', args: ['run', 'build'] };
698
+ }
699
+ return undefined;
700
+ }
701
+
702
+ private async ensureCleanGitWorktree(
703
+ stepsArg: ITsDeployDeployResult['steps'],
704
+ allowDirtyArg: boolean,
705
+ ): Promise<void> {
706
+ if (allowDirtyArg) {
707
+ stepsArg.push({ name: 'git-worktree', status: 'skipped', message: 'Dirty worktree check skipped by option.' });
708
+ return;
709
+ }
710
+ const result = await this.commandRunner({
711
+ command: 'git',
712
+ args: ['status', '--porcelain'],
713
+ cwd: this.projectDir,
714
+ env: this.env,
715
+ timeoutMs: 30000,
716
+ maxOutputBytes: 128 * 1024,
717
+ });
718
+ if (result.exitCode !== 0) {
719
+ stepsArg.push({ name: 'git-worktree', status: 'failed', message: 'Git status check failed.' });
720
+ throw new Error('Refusing to deploy because the project is not a readable git working tree. Commit the project first or pass --allowDirty.');
721
+ }
722
+ const dirtyOutput = result.stdout.trim();
723
+ if (dirtyOutput) {
724
+ stepsArg.push({ name: 'git-worktree', status: 'failed', message: 'Uncommitted changes are present.' });
725
+ throw new Error(`Refusing to deploy with uncommitted changes:\n${dirtyOutput}\nCommit the project first or pass --allowDirty.`);
726
+ }
727
+ stepsArg.push({ name: 'git-worktree', status: 'passed', message: 'Git working tree is clean.' });
728
+ }
729
+
730
+ private async resolveDockerBuildEnv(configArg: Record<string, any>): Promise<{
731
+ buildArgValues: Record<string, string>;
732
+ secretValues: string[];
733
+ }> {
734
+ const dockerConfig = configArg['@git.zone/tsdocker'] ?? {};
735
+ const szciConfig = configArg['@ship.zone/szci'] ?? {};
736
+ const buildArgEnvMap = {
737
+ ...(dockerConfig.buildArgEnvMap ?? {}),
738
+ ...(szciConfig.dockerBuildargEnvMap ?? {}),
739
+ } as Record<string, string>;
740
+ const buildArgValues: Record<string, string> = {};
741
+ const secretValues: string[] = [];
742
+ for (const [buildArg, envName] of Object.entries(buildArgEnvMap)) {
743
+ const value = this.env[envName];
744
+ if (!value) {
745
+ throw new Error(`Missing required Docker build arg environment value: ${envName}`);
746
+ }
747
+ buildArgValues[buildArg] = value;
748
+ secretValues.push(value);
749
+ }
750
+ return { buildArgValues, secretValues };
751
+ }
752
+
753
+ private async runCheckedCommand(
754
+ commandArg: ITsDeployCommandRunOptions,
755
+ stepNameArg: string,
756
+ stepsArg: ITsDeployDeployResult['steps'],
757
+ secretValuesArg: string[],
758
+ ): Promise<ITsDeployCommandRunResult> {
759
+ const result = await this.commandRunner(commandArg);
760
+ if (result.exitCode !== 0) {
761
+ stepsArg.push({ name: stepNameArg, status: 'failed', message: `${commandArg.command} exited with code ${result.exitCode}.` });
762
+ const output = this.redactText(`${result.stdout}\n${result.stderr}`, this.collectSecretValues(commandArg.env, secretValuesArg));
763
+ throw new Error(`${commandArg.command} failed with exit code ${result.exitCode}:\n${output}`.trim());
764
+ }
765
+ stepsArg.push({ name: stepNameArg, status: 'passed', message: `${commandArg.command} completed successfully.` });
766
+ return result;
767
+ }
768
+
769
+ private collectSecretValues(
770
+ envArg?: Record<string, string | undefined>,
771
+ additionalValuesArg: Array<string | undefined> = [],
772
+ ): string[] {
773
+ const values = new Set<string>();
774
+ const addValue = (valueArg?: string) => {
775
+ if (valueArg && valueArg.length >= 4) values.add(valueArg);
776
+ };
777
+ for (const value of additionalValuesArg) addValue(value);
778
+ if (envArg) {
779
+ for (const [key, value] of Object.entries(envArg)) {
780
+ if (/(token|password|secret|credential|private|auth|key)/i.test(key)) {
781
+ addValue(value);
782
+ }
783
+ }
784
+ }
785
+ return [...values];
786
+ }
787
+
788
+ private redactText(textArg: string, secretValuesArg: string[]): string {
789
+ let result = textArg;
790
+ for (const secret of secretValuesArg.filter(Boolean)) {
791
+ result = result.split(secret).join('[redacted]');
792
+ }
793
+ return result;
794
+ }
795
+
796
+ private extractDigest(outputArg: string): string | undefined {
797
+ const preferredPatterns = [
798
+ /pushing manifest for .+@(sha256:[a-f0-9]{64})/gi,
799
+ /exporting manifest list (sha256:[a-f0-9]{64})/gi,
800
+ /exporting manifest (sha256:[a-f0-9]{64})/gi,
801
+ ];
802
+ for (const pattern of preferredPatterns) {
803
+ const matches = [...outputArg.matchAll(pattern)];
804
+ const digest = matches.at(-1)?.[1];
805
+ if (digest) return digest;
806
+ }
807
+ return [...outputArg.matchAll(/sha256:[a-f0-9]{64}/gi)].at(-1)?.[0];
808
+ }
809
+
810
+ private async getCloudlyDeployments(
811
+ originArg: string,
812
+ identityArg: plugins.servezoneInterfaces.data.IIdentity,
813
+ serviceIdArg: string,
814
+ ): Promise<ITsDeployDeploymentSummary[]> {
815
+ const response = await this.fireCloudlyRequest<plugins.servezoneInterfaces.requests.deployment.IReq_Any_Cloudly_GetDeploymentsByService>(
816
+ originArg,
817
+ 'getDeploymentsByService',
818
+ { identity: identityArg, serviceId: serviceIdArg },
819
+ );
820
+ return response.deployments.map(deployment => ({
821
+ deploymentId: deployment.id,
822
+ serviceId: deployment.serviceId,
823
+ serviceName: deployment.serviceName,
824
+ status: deployment.status,
825
+ healthStatus: deployment.healthStatus,
826
+ nodeName: deployment.nodeName,
827
+ version: deployment.version,
828
+ containerId: deployment.containerId,
829
+ deployedAt: deployment.deployedAt,
830
+ updatedAt: deployment.updatedAt,
831
+ }));
832
+ }
833
+
834
+ private async waitForHealthyDeployment(
835
+ originArg: string,
836
+ identityArg: plugins.servezoneInterfaces.data.IIdentity,
837
+ serviceIdArg: string,
838
+ waitSecondsArg: number,
839
+ ): Promise<ITsDeployDeploymentSummary[]> {
840
+ const deadline = Date.now() + waitSecondsArg * 1000;
841
+ let deployments: ITsDeployDeploymentSummary[] = [];
842
+ while (Date.now() <= deadline) {
843
+ deployments = await this.getCloudlyDeployments(originArg, identityArg, serviceIdArg);
844
+ if (deployments.some(deployment => deployment.status === 'running' && deployment.healthStatus === 'healthy')) {
845
+ return deployments;
846
+ }
847
+ await new Promise(resolve => setTimeout(resolve, 5000));
848
+ }
849
+ throw new Error(`No healthy running deployment observed for service ${serviceIdArg} within ${waitSecondsArg}s.`);
850
+ }
851
+
852
+ private async verifyUrl(urlArg: string): Promise<number> {
853
+ const controller = new AbortController();
854
+ const timeoutRef = setTimeout(() => controller.abort(), defaultVerifyUrlTimeoutMs);
855
+ timeoutRef.unref?.();
856
+ try {
857
+ const response = await fetch(urlArg, { method: 'HEAD', signal: controller.signal });
858
+ return response.status;
859
+ } catch (error) {
860
+ if ((error as { name?: string }).name === 'AbortError') {
861
+ throw new Error(`Verification URL timed out after ${defaultVerifyUrlTimeoutMs}ms: ${urlArg}`);
862
+ }
863
+ throw error;
864
+ } finally {
865
+ clearTimeout(timeoutRef);
866
+ }
867
+ }
252
868
  }