@trayai/tray-sync-cli 1.0.6 → 1.0.8

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,6 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { readdir as fsReaddir, readFile as fsReadFile, rm as fsRm } from "node:fs/promises";
3
- import { Command, InvalidArgumentError } from "commander";
3
+ import { Command, InvalidArgumentError, Option } from "commander";
4
4
  import { createTrayClientForRegion } from "../api/trayClient.js";
5
5
  import { readCredentials } from "../config/credentials.js";
6
6
  import { readEnvironments } from "../config/environments.js";
@@ -12,14 +12,15 @@ import { readTrayYaml } from "../config/trayYaml.js";
12
12
  import { resolveContainedSafe } from "../lib/pathContainment.js";
13
13
  import { entityDisplayName } from "../lib/exportStructure.js";
14
14
  import { importProject, previewImport } from "../lib/promoteExecute.js";
15
- import { toProjectImportImpact } from "../lib/promoteReport.js";
16
- import { isFullyResolved, resolveProjectRequirements, scaffoldUnresolvedAuthentications, } from "../lib/promoteTarget.js";
15
+ import { summarizeImpactForVersionDescription, toProjectImportImpact, } from "../lib/promoteReport.js";
16
+ import { computeNextVersionNumber, createProjectVersion, getLatestProjectVersion, isFullyResolved, resolveProjectRequirements, scaffoldUnresolvedAuthentications, } from "../lib/promoteTarget.js";
17
17
  import { resolveToken } from "../lib/tokenResolution.js";
18
18
  import { isUuid } from "../lib/uuid.js";
19
19
  import { OutputFormats, parseFormat } from "../lib/outputFormat.js";
20
20
  import { checkProjectDrift, DriftStatuses } from "./status.js";
21
- import { printError, printInvalidAuthMappings, printLocalDrift, printMutuallyExclusiveResumeAndProject, printNoTargetProjectMapped, printProjectError, printProjectImportImpact, printProjectNoStateFile, printProjectNotPulledLocally, printPromoted, printTargetEnvNotFound, printTrayYamlNotFound, printUnresolvedRequirements, printWouldBePromoted, } from "../views/promote/promote.v2.js";
21
+ import { printError, printInvalidAuthMappings, printLocalDrift, printMutuallyExclusiveMajorAndVersion, printMutuallyExclusiveResumeAndProject, printNoTargetProjectMapped, printProjectError, printProjectImportImpact, printProjectNoStateFile, printProjectNotPulledLocally, printPromoted, printTargetEnvNotFound, printTrayYamlNotFound, printUnresolvedRequirements, printVersionCreationFailed, printWouldBePromoted, } from "../views/promote/promote.v2.js";
22
22
  import { printPromoteResultJson } from "../views/promote/shared.js";
23
+ const DEFAULT_VERSION_TITLE = "Promoted via tray-sync-cli";
23
24
  function findProjectDir(existingDirs, projectId) {
24
25
  return existingDirs.find((name) => name.split("--")[0] === projectId);
25
26
  }
@@ -50,6 +51,12 @@ export async function runPromote(deps, options) {
50
51
  printMutuallyExclusiveResumeAndProject(stderr);
51
52
  return 1;
52
53
  }
54
+ if (options.major && options.versionNumber) {
55
+ if (asJson)
56
+ return fail({ reason: "mutually_exclusive_major_and_version" });
57
+ printMutuallyExclusiveMajorAndVersion(stderr);
58
+ return 1;
59
+ }
53
60
  const trayYaml = await readTrayYaml(cwd);
54
61
  if (!trayYaml) {
55
62
  if (asJson)
@@ -208,6 +215,34 @@ export async function runPromote(deps, options) {
208
215
  continue;
209
216
  }
210
217
  await importProject(targetClient, targetProjectId, payload);
218
+ if (options.version ?? true) {
219
+ try {
220
+ const mode = options.major ? "major" : "minor";
221
+ const versionNumber = options.versionNumber ??
222
+ computeNextVersionNumber(await getLatestProjectVersion(targetClient, targetProjectId), mode);
223
+ const title = options.versionTitle ?? DEFAULT_VERSION_TITLE;
224
+ const description = options.versionDescription ?? summarizeImpactForVersionDescription(impact);
225
+ const created = await createProjectVersion(targetClient, targetProjectId, versionNumber, title, description);
226
+ impact.createdVersion = {
227
+ versionNumber: created.versionNumber,
228
+ title: created.title,
229
+ description: created.description,
230
+ };
231
+ }
232
+ catch (err) {
233
+ if (asJson) {
234
+ return fail({
235
+ reason: "version_creation_failed",
236
+ project_id: projectId,
237
+ source_project_name: sourceProjectName,
238
+ message: err.message,
239
+ target_project_id: targetProjectId,
240
+ });
241
+ }
242
+ printVersionCreationFailed(stderr, projectId, sourceProjectName, err.message, targetProjectId);
243
+ return 1;
244
+ }
245
+ }
211
246
  if (!asJson) {
212
247
  printPromoted(stdout, projectId, sourceProjectName, options.to);
213
248
  printProjectImportImpact(stdout, impact);
@@ -252,6 +287,11 @@ export const promoteCommand = new Command("promote")
252
287
  .option("--resume", "resume a previously interrupted promote")
253
288
  .option("--dry-run", "report what would change without creating projects or importing")
254
289
  .option("--auto-create-projects", "create the target project when no mapping exists yet")
290
+ .addOption(new Option("--no-version", "skip creating a target project version after import").helpGroup("Version options:"))
291
+ .addOption(new Option("--major", "bump the major version instead of minor").helpGroup("Version options:"))
292
+ .addOption(new Option("--version-number <major.minor>", "explicit target version number (overrides --major)").helpGroup("Version options:"))
293
+ .addOption(new Option("--version-title <title>", "title for the created version").helpGroup("Version options:"))
294
+ .addOption(new Option("--version-description <description>", "description for the created version (auto-generated from promotion impact if omitted)").helpGroup("Version options:"))
255
295
  .option("-t, --token <token>", "Tray API token (used for both source and target)")
256
296
  .option("--format <format>", "human or json", parseFormat, OutputFormats.HUMAN)
257
297
  .action(async (opts) => {
@@ -274,6 +314,11 @@ export const promoteCommand = new Command("promote")
274
314
  dryRun: Boolean(opts.dryRun),
275
315
  autoCreateProjects: Boolean(opts.autoCreateProjects),
276
316
  format: opts.format,
317
+ version: opts.version,
318
+ major: Boolean(opts.major),
319
+ versionNumber: opts.versionNumber,
320
+ versionTitle: opts.versionTitle,
321
+ versionDescription: opts.versionDescription,
277
322
  });
278
323
  process.exitCode = exitCode;
279
324
  });
@@ -59,6 +59,25 @@ export async function listProjectIds(client, workspaceId) {
59
59
  const result = (await client.request("GET", `/v2/projects/workspaces/${workspaceId}`));
60
60
  return result.projects.map((p) => requireUuid(p.id, "listProjects: projects[].id"));
61
61
  }
62
+ async function removeOrphanedEntityDirs(projectDirPath, entityParentDir, currentIds) {
63
+ await assertNoSymlinkInPath(projectDirPath, entityParentDir);
64
+ let existingDirNames;
65
+ try {
66
+ existingDirNames = await fsReaddir(entityParentDir);
67
+ }
68
+ catch {
69
+ existingDirNames = [];
70
+ }
71
+ for (const dirName_ of existingDirNames) {
72
+ const prefix = dirName_.split("--")[0];
73
+ if (!isUuid(prefix) || currentIds.has(prefix)) {
74
+ continue;
75
+ }
76
+ const staleDir = resolveContained(entityParentDir, dirName_);
77
+ await assertNoSymlinkInPath(projectDirPath, staleDir);
78
+ await fsRm(staleDir, { recursive: true, force: true });
79
+ }
80
+ }
62
81
  export async function writeExportedProject(projectDirPath, exportStructure, stdout) {
63
82
  const project = exportStructure.projects[0];
64
83
  const projectJsonPath = path.join(projectDirPath, "project.json");
@@ -75,6 +94,7 @@ export async function writeExportedProject(projectDirPath, exportStructure, stdo
75
94
  for (const { field, dirName, fileName, trayType } of ASSET_DIRS) {
76
95
  const entities = exportStructure[field] ?? [];
77
96
  const entityParentDir = path.join(projectDirPath, dirName);
97
+ const currentIds = new Set(entities.map((entity) => entity.id));
78
98
  for (const entity of entities) {
79
99
  const entityId = requireUuid(entity.id, `exportProject: ${field}[].id`);
80
100
  const entityDirName_ = entityDirName(entityId, entityDisplayName(entity));
@@ -113,6 +133,7 @@ export async function writeExportedProject(projectDirPath, exportStructure, stdo
113
133
  }
114
134
  }
115
135
  }
136
+ await removeOrphanedEntityDirs(projectDirPath, entityParentDir, currentIds);
116
137
  }
117
138
  const solutionDropped = Boolean(exportStructure.solution);
118
139
  if (solutionDropped) {
@@ -252,6 +273,10 @@ export async function runPull(deps, options) {
252
273
  }
253
274
  }
254
275
  if (unchanged) {
276
+ for (const { field, dirName } of ASSET_DIRS) {
277
+ const entities = exportStructure[field] ?? [];
278
+ await removeOrphanedEntityDirs(dir, path.join(dir, dirName), new Set(entities.map((e) => e.id)));
279
+ }
255
280
  await writeProjectState(dir, { ...existingState, last_sync: new Date().toISOString() });
256
281
  if (asJson) {
257
282
  events.push({ project_id: projectId, project_name: projectName, action: "pulled_unchanged", renamed_dir: renamedDir });
@@ -20,4 +20,22 @@ export function toProjectImportImpact(projectName, projectId, impact) {
20
20
  ],
21
21
  };
22
22
  }
23
+ export function summarizeImpactForVersionDescription(impact) {
24
+ function countsByKind(entities) {
25
+ const counts = { created: 0, updated: 0, removed: 0 };
26
+ for (const entity of entities)
27
+ counts[entity.kind]++;
28
+ return Object.entries(counts)
29
+ .filter(([, count]) => count > 0)
30
+ .map(([kind, count]) => `${count} ${kind}`);
31
+ }
32
+ const parts = [];
33
+ const workflowCounts = countsByKind(impact.workflows);
34
+ if (workflowCounts.length > 0)
35
+ parts.push(`${workflowCounts.join(", ")} workflows`);
36
+ const configCounts = countsByKind(impact.config);
37
+ if (configCounts.length > 0)
38
+ parts.push(`${configCounts.join(", ")} config keys`);
39
+ return parts.length > 0 ? parts.join("; ") : "No changes detected";
40
+ }
23
41
  //# sourceMappingURL=promoteReport.js.map
@@ -96,6 +96,30 @@ export function validateAuthenticationMappings(mapping) {
96
96
  .filter(([, authenticationId]) => !isUuid(authenticationId))
97
97
  .map(([authExportId, value]) => ({ authExportId, value }));
98
98
  }
99
+ export async function getLatestProjectVersion(client, targetProjectId) {
100
+ const result = (await client.request("GET", `/v1/projects/${targetProjectId}/versions?last=1`));
101
+ const latest = result.elements?.[0];
102
+ if (!latest)
103
+ return null;
104
+ const [major, minor] = latest.versionNumber.split(".").map(Number);
105
+ return { major, minor };
106
+ }
107
+ export function computeNextVersionNumber(current, mode) {
108
+ if (!current)
109
+ return "1.0";
110
+ if (mode === "major")
111
+ return `${current.major + 1}.0`;
112
+ return `${current.major}.${current.minor + 1}`;
113
+ }
114
+ export async function createProjectVersion(client, targetProjectId, versionNumber, title, description) {
115
+ const result = (await client.request("POST", `/v1/projects/${targetProjectId}/versions/${versionNumber}`, { title, description }));
116
+ return {
117
+ versionNumber: result.versionNumber,
118
+ title: result.title,
119
+ description: result.description,
120
+ created: result.created,
121
+ };
122
+ }
99
123
  export async function resolveProjectRequirements(client, deps, options) {
100
124
  const targetResolution = await resolveTargetProjectId(client, {
101
125
  projectDirPath: options.projectDirPath,
@@ -4,6 +4,9 @@ import { printIssueBlock } from "../shared.js";
4
4
  export function printMutuallyExclusiveResumeAndProject(stderr) {
5
5
  stderr(`${FAILURE_ICON} ${ansis.bold("--resume")} and ${ansis.bold("--project")} are mutually exclusive.`);
6
6
  }
7
+ export function printMutuallyExclusiveMajorAndVersion(stderr) {
8
+ stderr(`${FAILURE_ICON} ${ansis.bold("--major")} and ${ansis.bold("--version")} are mutually exclusive.`);
9
+ }
7
10
  export function printTrayYamlNotFound(stderr) {
8
11
  stderr(`${FAILURE_ICON} tray.yaml not found. Run \`${ansis.bold("tray init")}\` first.`);
9
12
  }
@@ -73,6 +76,12 @@ export function printProjectImportImpact(stdout, impact) {
73
76
  stdout(` ${ansis.bold(c.kind)}: ${c.name}`);
74
77
  }
75
78
  }
79
+ if (impact.createdVersion) {
80
+ stdout(` version: ${ansis.bold(impact.createdVersion.versionNumber)} — ${impact.createdVersion.title}`);
81
+ }
76
82
  stdout("");
77
83
  }
84
+ export function printVersionCreationFailed(stderr, projectId, sourceProjectName, message, targetProjectId) {
85
+ stderr(`${FAILURE_ICON} Project ${ansis.bold(`${projectId} (${sourceProjectName})`)} was promoted to target project ${targetProjectId}, but creating a version failed: ${message}`);
86
+ }
78
87
  //# sourceMappingURL=promote.v2.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trayai/tray-sync-cli",
3
- "version": "1.0.6",
3
+ "version": "1.0.8",
4
4
  "description": "CLI tool to clone Tray projects and related assets to a local directory, and promote them between Tray environments",
5
5
  "bin": {
6
6
  "tray": "dist/cli.js"