@trayai/tray-sync-cli 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,6 +25,7 @@ npm install -g @trayai/tray-sync-cli
25
25
  | `tray env add <name>` | Add a target environment |
26
26
  | `tray env list` | List source and target environments with connectivity status |
27
27
  | `tray env remove <name>` | Remove a target environment and its per-project mappings |
28
+ | `tray env discover [env]` | List an env's projects (id + name, default) and/or matching authentications — `env` defaults to `source` |
28
29
  | `tray env resolve <env-name>` | Report unresolved auth requirements for a target env, with candidate matches |
29
30
  | `tray auth set -w <workspace-id> -r <region> -t <token>` | Set (or overwrite) a workspace's token |
30
31
  | `tray auth remove -w <workspace-id>` | Remove a workspace's stored token |
@@ -66,6 +67,14 @@ Example:
66
67
  tray init -r us1 -w 11111111-1111-1111-1111-111111111111 -t <token> -p <project_id>
67
68
  ```
68
69
 
70
+ Don't know the project ID yet? Run `init` without `-p` (an empty scaffold is fine), then:
71
+
72
+ ```bash
73
+ tray env discover
74
+ ```
75
+
76
+ lists every project in your source workspace — id + name — so you don't have to dig it out of Tray's UI.
77
+
69
78
  A few notes on the flags:
70
79
 
71
80
  - **`-p <project-id>`** pulls that project immediately after scaffolding. Omit it and `init` just scaffolds an empty repository — you can pull specific projects later with: `tray pull -p <project_id>` OR `tray project add <project-id>` followed by `tray pull`.
@@ -175,7 +184,3 @@ The key (`<source_authentication_id>`) is the source-side auth group ID — leav
175
184
  #### If you get it wrong
176
185
 
177
186
  If a required field is missing or still holds a placeholder value, `tray promote` refuses the attempt and tells you exactly what's outstanding and where to fix it — it won't silently promote a project into the wrong target, or with an unresolved authentication.
178
-
179
- ## Contributing
180
-
181
- For local development setup, the smoke/integration test, and internal architecture notes, see the "For contributors to this repo" section in [docs/getting-started-guide.md](docs/getting-started-guide.md#for-contributors-to-this-repo).
@@ -15,14 +15,15 @@ import { PUBLIC_REGIONS, parseRegion } from "../lib/regionParsing.js";
15
15
  import { resolveToken } from "../lib/tokenResolution.js";
16
16
  import { isUuid } from "../lib/uuid.js";
17
17
  import { OutputFormats, parseFormat } from "../lib/outputFormat.js";
18
- import { listWorkspaceAuthentications, matchAuthCandidates, prioritizeAuths, } from "../lib/authDiscovery.js";
18
+ import { dedupeUsedAuths, listWorkspaceAuthentications, matchAuthCandidates, prioritizeAuths, usedAuthsInProject, } from "../lib/authDiscovery.js";
19
19
  import { isFullyResolved, resolveProjectRequirements, } from "../lib/promoteTarget.js";
20
20
  import { checkProjectDrift, DriftStatuses } from "./status.js";
21
- import { printAddedEnvironment, printConnectivityCheck, printEnvAlreadyExists, printEnvNotConfigured, printEnvResolveProject, printEnvResolveProjectFullyResolved, printInvalidEnvName, printNoTargetEnvironments, printNoTokenSkippingConnectivity, printRemovedCredentials, printRemovedEnvironment, printEnvResolveProjectError, printRemovedMapping, printSourceEnvTable, printTargetEnvHeader, printTargetEnvTable, printTokenNotSaved, printTrayYamlNotFound, } from "../views/env/env.v2.js";
21
+ import { printAddedEnvironment, printConnectivityCheck, printDiscoverAuthsHeader, printDiscoverEnvHeader, printDiscoverNoAuthsUsed, printDiscoverNoProjects, printDiscoverProjectsTable, printEnvAlreadyExists, printEnvNotConfigured, printEnvNotFound, printEnvResolveProject, printReservedEnvName, printEnvResolveProjectFullyResolved, printInvalidEnvName, printNoTargetEnvironments, printNoTokenSkippingConnectivity, printRemovedCredentials, printRemovedEnvironment, printEnvResolveProjectError, printRemovedMapping, printSourceAuthsTable, printSourceEnvTable, printTargetAuthsTable, printTargetEnvHeader, printTargetEnvTable, printTokenNotSaved, printTrayYamlNotFound, } from "../views/env/env.v2.js";
22
22
  import { printEnvListJson, printEnvResolveResultJson, } from "../views/env/shared.js";
23
23
  import { printError, printInvalidAuthMappings, printLocalDrift, printNoTargetProjectMapped, printProjectError, printProjectNoStateFile, printProjectNotPulledLocally, printTargetEnvNotFound, } from "../views/promote/promote.v2.js";
24
24
  import { FAILURE_ICON, SUCCESS_ICON } from "../views/icons.js";
25
25
  const ENV_NAME_RE = /^[a-zA-Z0-9_-]+$/;
26
+ export const SOURCE_ENV_NAME = "source";
26
27
  function emptyMapping() {
27
28
  return { authentications: {}, config: {}, connectors: [], services: [] };
28
29
  }
@@ -60,6 +61,10 @@ export async function runEnvAdd(deps, options) {
60
61
  printInvalidEnvName(stderr, ENV_NAME_RE, options.name);
61
62
  return 1;
62
63
  }
64
+ if (options.name === SOURCE_ENV_NAME) {
65
+ printReservedEnvName(stderr, options.name);
66
+ return 1;
67
+ }
63
68
  const environments = await readEnvironments(cwd);
64
69
  if (environments.environments[options.name]) {
65
70
  printEnvAlreadyExists(stderr, options.name);
@@ -194,6 +199,119 @@ export async function runEnvRemove(deps, options) {
194
199
  }
195
200
  return 0;
196
201
  }
202
+ async function listProjectsWithNames(client, workspaceId) {
203
+ const result = (await client.request("GET", `/v2/projects/workspaces/${workspaceId}`));
204
+ return result.projects;
205
+ }
206
+ function resolveDiscoverTarget(envName, trayYaml, environments) {
207
+ if (envName === SOURCE_ENV_NAME) {
208
+ return { region: trayYaml.region, workspaceId: trayYaml.workspace_id };
209
+ }
210
+ const entry = environments.environments[envName];
211
+ if (!entry)
212
+ return undefined;
213
+ return { region: entry.region, workspaceId: entry.workspace_id };
214
+ }
215
+ async function usedAuthsAcrossLocalProjects(deps, cwd, projectIds) {
216
+ const projectsRoot = path.join(cwd, "projects");
217
+ let existingDirs;
218
+ try {
219
+ existingDirs = await deps.readdir(projectsRoot);
220
+ }
221
+ catch {
222
+ existingDirs = [];
223
+ }
224
+ const used = [];
225
+ for (const projectId of projectIds) {
226
+ const dirName = existingDirs.find((name) => name.split("--")[0] === projectId);
227
+ if (!dirName)
228
+ continue;
229
+ const projectDirPath = path.join(projectsRoot, dirName);
230
+ const projectFiles = await deps.readdirRecursive(projectDirPath);
231
+ used.push(...(await usedAuthsInProject(deps.readFile, projectDirPath, projectFiles)));
232
+ }
233
+ return dedupeUsedAuths(used);
234
+ }
235
+ export async function runEnvDiscover(deps, options) {
236
+ const { cwd, env, createClient, readdir, readFile, readdirRecursive, stdout, stderr } = deps;
237
+ const trayYaml = await readTrayYaml(cwd);
238
+ if (!trayYaml) {
239
+ printTrayYamlNotFound(stderr);
240
+ return 1;
241
+ }
242
+ const environments = await readEnvironments(cwd);
243
+ const target = resolveDiscoverTarget(options.env, trayYaml, environments);
244
+ if (!target) {
245
+ printEnvNotFound(stderr, options.env);
246
+ return 1;
247
+ }
248
+ const credentials = await readCredentials(env);
249
+ let token;
250
+ try {
251
+ token = resolveToken({
252
+ tokenFlag: options.token,
253
+ credentials,
254
+ workspaceId: target.workspaceId,
255
+ envName: options.env === SOURCE_ENV_NAME ? undefined : options.env,
256
+ env,
257
+ });
258
+ }
259
+ catch (err) {
260
+ printError(stderr, err.message);
261
+ return 1;
262
+ }
263
+ const client = createClient(target.region, token);
264
+ const showProjects = options.all || options.projects || (!options.projects && !options.authentications);
265
+ const showAuths = Boolean(options.all || options.authentications);
266
+ const isSource = options.env === SOURCE_ENV_NAME;
267
+ printDiscoverEnvHeader(stdout, options.env, isSource);
268
+ if (showProjects) {
269
+ const projects = await listProjectsWithNames(client, target.workspaceId);
270
+ const rows = projects
271
+ .map((p) => ({ id: p.id, name: p.name }))
272
+ .sort((a, b) => a.name.localeCompare(b.name));
273
+ if (rows.length === 0) {
274
+ printDiscoverNoProjects(stdout, options.env);
275
+ }
276
+ else {
277
+ printDiscoverProjectsTable(stdout, rows);
278
+ }
279
+ }
280
+ if (showAuths) {
281
+ const used = await usedAuthsAcrossLocalProjects({ readdir, readFile, readdirRecursive }, cwd, trayYaml.projects);
282
+ if (used.length === 0) {
283
+ printDiscoverNoAuthsUsed(stdout);
284
+ }
285
+ else {
286
+ const { authentications } = await listWorkspaceAuthentications(client, target.region, target.workspaceId);
287
+ printDiscoverAuthsHeader(stdout);
288
+ if (isSource) {
289
+ const rows = used.map((requirement) => {
290
+ const self = authentications.find((a) => a.group === requirement.group);
291
+ return { name: self?.name ?? requirement.title, id: self?.id ?? "", service: requirement.service };
292
+ });
293
+ rows.sort((a, b) => a.service.name.localeCompare(b.service.name) || a.name.localeCompare(b.name));
294
+ printSourceAuthsTable(stdout, rows);
295
+ }
296
+ else {
297
+ const matchedTitlesByCandidateId = new Map();
298
+ for (const requirement of used) {
299
+ for (const candidate of matchAuthCandidates(requirement, authentications)) {
300
+ const titles = matchedTitlesByCandidateId.get(candidate.id) ?? [];
301
+ titles.push(requirement.title);
302
+ matchedTitlesByCandidateId.set(candidate.id, titles);
303
+ }
304
+ }
305
+ const rows = authentications
306
+ .filter((a) => matchedTitlesByCandidateId.has(a.id))
307
+ .map((a) => ({ name: a.name, id: a.id, service: a.service, matchedTitles: matchedTitlesByCandidateId.get(a.id) }));
308
+ rows.sort((a, b) => a.service.name.localeCompare(b.service.name) || a.name.localeCompare(b.name));
309
+ printTargetAuthsTable(stdout, rows);
310
+ }
311
+ }
312
+ }
313
+ return 0;
314
+ }
197
315
  function findProjectDirName(existingDirs, projectId) {
198
316
  return existingDirs.find((name) => name.split("--")[0] === projectId);
199
317
  }
@@ -310,7 +428,7 @@ export async function runEnvResolve(deps, options) {
310
428
  }
311
429
  if (resolution.status === "no_target_project_mapped") {
312
430
  if (!options.dryRun) {
313
- printNoTargetProjectMapped(stderr, sourceProjectName, projectId, options.to, resolution.mappingsPath);
431
+ printNoTargetProjectMapped(stderr, sourceProjectName, projectId, options.to, resolution.mappingsPath, "env resolve");
314
432
  return 1;
315
433
  }
316
434
  anyUnresolved = true;
@@ -518,6 +636,24 @@ envCommand
518
636
  });
519
637
  process.exitCode = exitCode;
520
638
  });
639
+ envCommand
640
+ .command("discover")
641
+ .description("List available projects and/or authentications for an env (default: source) — --projects (default) shows name+id; --authentications shows workspace auths matching what locally pulled projects use; --all shows both")
642
+ .argument("[env]", `"${SOURCE_ENV_NAME}" (default) or a target environment name (see \`tray env list\`)`, SOURCE_ENV_NAME)
643
+ .option("--projects", "list the env's projects (name + id) — the default when no flag is given")
644
+ .option("--authentications", "list authentications in the env's workspace matching what locally pulled source projects use")
645
+ .option("--all", "shorthand for --projects and --authentications together")
646
+ .option("-t, --token <token>", "Tray API token")
647
+ .action(async (envName, opts) => {
648
+ const exitCode = await runEnvDiscover(realDeps(), {
649
+ env: envName,
650
+ token: opts.token,
651
+ projects: Boolean(opts.projects),
652
+ authentications: Boolean(opts.authentications),
653
+ all: Boolean(opts.all),
654
+ });
655
+ process.exitCode = exitCode;
656
+ });
521
657
  envCommand
522
658
  .command("resolve")
523
659
  .description("Report unresolved auth requirements for a target env, with candidate matches")
@@ -163,7 +163,7 @@ export async function runPromote(deps, options) {
163
163
  mappings_path: resolution.mappingsPath,
164
164
  });
165
165
  }
166
- printNoTargetProjectMapped(stderr, sourceProjectName, projectId, options.to, resolution.mappingsPath);
166
+ printNoTargetProjectMapped(stderr, sourceProjectName, projectId, options.to, resolution.mappingsPath, "promote");
167
167
  return 1;
168
168
  }
169
169
  if (resolution.status === "invalid_auth_mappings") {
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import path from "node:path";
2
3
  import { urlsForRegion } from "./region.js";
3
4
  const GET_AUTHENTICATIONS_QUERY = `
4
5
  query GetAuthenticationsPrivate(
@@ -73,4 +74,56 @@ export function matchAuthCandidates(requirement, authentications) {
73
74
  export function prioritizeAuths(candidates) {
74
75
  return candidates;
75
76
  }
77
+ export function extractUsedAuths(workflow) {
78
+ const steps = workflow.steps;
79
+ if (typeof steps !== "object" || steps === null) {
80
+ return [];
81
+ }
82
+ const used = [];
83
+ for (const step of Object.values(steps)) {
84
+ if (typeof step !== "object" || step === null)
85
+ continue;
86
+ const authentication = step.authentication;
87
+ if (typeof authentication !== "object" || authentication === null)
88
+ continue;
89
+ const auth = authentication;
90
+ const group = auth.group;
91
+ const title = auth.title;
92
+ const serviceName = auth.service_name;
93
+ const serviceVersion = auth.service_version;
94
+ if (typeof group !== "string" ||
95
+ typeof title !== "string" ||
96
+ typeof serviceName !== "string" ||
97
+ (typeof serviceVersion !== "string" && typeof serviceVersion !== "number")) {
98
+ continue;
99
+ }
100
+ used.push({ group, title, service: { name: serviceName, version: String(serviceVersion) } });
101
+ }
102
+ return used;
103
+ }
104
+ export function dedupeUsedAuths(used) {
105
+ const seen = new Map();
106
+ for (const auth of used) {
107
+ if (!seen.has(auth.group)) {
108
+ seen.set(auth.group, auth);
109
+ }
110
+ }
111
+ return [...seen.values()];
112
+ }
113
+ export async function usedAuthsInProject(readFile, projectDirPath, projectFiles) {
114
+ const workflowJsonPaths = projectFiles.filter((relativePath) => relativePath.startsWith(`workflows${path.sep}`) && relativePath.endsWith(`${path.sep}workflow.json`));
115
+ const used = [];
116
+ for (const relativePath of workflowJsonPaths) {
117
+ let workflow;
118
+ try {
119
+ const raw = await readFile(path.join(projectDirPath, relativePath));
120
+ workflow = JSON.parse(raw);
121
+ }
122
+ catch {
123
+ continue;
124
+ }
125
+ used.push(...extractUsedAuths(workflow));
126
+ }
127
+ return used;
128
+ }
76
129
  //# sourceMappingURL=authDiscovery.js.map
@@ -1,9 +1,12 @@
1
1
  import ansis from "ansis";
2
2
  import Table from "cli-table3";
3
- import { FAILURE_ICON, INFO_ICON, WARNING_ICON } from "../icons.js";
3
+ import { FAILURE_ICON, INFO_ICON, SUCCESS_ICON, WARNING_ICON } from "../icons.js";
4
4
  export function printInvalidEnvName(stderr, nameRe, name) {
5
5
  stderr(`${FAILURE_ICON} Environment name must match ${nameRe}: "${name}"`);
6
6
  }
7
+ export function printReservedEnvName(stderr, name) {
8
+ stderr(`${FAILURE_ICON} "${ansis.bold(name)}" is reserved (used by \`${ansis.bold("tray env discover")}\` to mean the source workspace) and cannot be a target environment name.`);
9
+ }
7
10
  export function printEnvAlreadyExists(stderr, name) {
8
11
  stderr(`${FAILURE_ICON} Environment "${ansis.bold(name)}" already exists. Run \`${ansis.bold("tray env remove")}\` first to replace it.`);
9
12
  }
@@ -34,6 +37,9 @@ export function printConnectivityCheck(stdout, description) {
34
37
  export function printTrayYamlNotFound(stderr) {
35
38
  stderr(`${FAILURE_ICON} tray.yaml not found. Run \`${ansis.bold("tray init")}\` first.`);
36
39
  }
40
+ export function printEnvNotFound(stderr, name) {
41
+ stderr(`${FAILURE_ICON} "${ansis.bold(name)}" is not "${ansis.bold("source")}" and is not a configured target environment. See \`${ansis.bold("tray env list")}\` for configured names.`);
42
+ }
37
43
  function envTable() {
38
44
  return new Table({
39
45
  head: ["Environment", "Region", "Workspace ID", "Status"].map((h) => ansis.bold(h)),
@@ -73,7 +79,7 @@ export function printRemovedCredentials(stdout, workspaceId) {
73
79
  stdout(`${ansis.bold("Removed")} credentials for workspace ${workspaceId}.`);
74
80
  }
75
81
  export function printEnvResolveProjectFullyResolved(stdout, projectId, sourceProjectName) {
76
- stdout(`${ansis.bold(`${sourceProjectName} (${projectId})`)}: fully resolved, nothing to do`);
82
+ stdout(`${SUCCESS_ICON} ${ansis.bold(`${sourceProjectName} (${projectId})`)}: fully resolved, nothing to do`);
77
83
  }
78
84
  export function printEnvResolveProjectError(stdout, projectId, sourceProjectName, error) {
79
85
  stdout(`${ansis.bold(`${sourceProjectName} (${projectId})`)}:`);
@@ -112,4 +118,51 @@ export function printEnvResolveProject(stdout, project) {
112
118
  stdout(` new config keys require mapping — see mappings.json's config entry`);
113
119
  }
114
120
  }
121
+ export function printDiscoverEnvHeader(stdout, envName, isSource) {
122
+ stdout(ansis.bold(isSource ? "Environment: source" : `Environment: target (${envName})`));
123
+ stdout("");
124
+ }
125
+ export function printDiscoverNoProjects(stdout, envName) {
126
+ stdout(`${INFO_ICON} No projects found in "${ansis.bold(envName)}".`);
127
+ }
128
+ export function printDiscoverProjectsTable(stdout, rows) {
129
+ stdout("[Projects]");
130
+ const table = new Table({
131
+ head: ["Name", "ID"].map((h) => ansis.bold(h)),
132
+ style: { head: [] },
133
+ });
134
+ for (const row of rows) {
135
+ table.push([row.name, row.id]);
136
+ }
137
+ stdout(table.toString());
138
+ stdout("");
139
+ }
140
+ export function printDiscoverNoAuthsUsed(stdout) {
141
+ stdout(`${INFO_ICON} No locally pulled project uses an authentication.`);
142
+ }
143
+ export function printDiscoverAuthsHeader(stdout) {
144
+ stdout("[Available Authentications]");
145
+ }
146
+ export function printSourceAuthsTable(stdout, rows) {
147
+ const table = new Table({
148
+ head: ["Name", "ID", "Service"].map((h) => ansis.bold(h)),
149
+ style: { head: [] },
150
+ });
151
+ for (const row of rows) {
152
+ table.push([row.name, row.id, `${row.service.name} v${row.service.version}`]);
153
+ }
154
+ stdout(table.toString());
155
+ stdout("");
156
+ }
157
+ export function printTargetAuthsTable(stdout, rows) {
158
+ const table = new Table({
159
+ head: ["Name", "Service", "ID", "Match"].map((h) => ansis.bold(h)),
160
+ style: { head: [] },
161
+ });
162
+ for (const row of rows) {
163
+ table.push([row.name, `${row.service.name} v${row.service.version}`, row.id, row.matchedTitles.join("\n")]);
164
+ }
165
+ stdout(table.toString());
166
+ stdout("");
167
+ }
115
168
  //# sourceMappingURL=env.v2.js.map
@@ -22,9 +22,17 @@ export function printProjectNoStateFile(stderr, projectId) {
22
22
  export function printLocalDrift(stderr, projectId, dirName, dirty) {
23
23
  printIssueBlock(stderr, `Project ${ansis.bold(`${projectId} (${dirName})`)} has local drift; refusing to promote:`, dirty.map((d) => `${d.status}\t${d.path}`), `Run \`${ansis.bold("tray pull")}\` to refresh, or resolve the drift, then retry.`);
24
24
  }
25
- export function printNoTargetProjectMapped(stderr, sourceProjectName, projectId, envName, mappingsPath) {
26
- stderr(`${FAILURE_ICON} ${ansis.bold(`"${sourceProjectName}" (${projectId})`)} has no target project mapped for "${ansis.bold(envName)}".`);
27
- stderr(`Fill in target_project_id in ${mappingsPath}, or re-run with ${ansis.bold("--auto-create-projects")}.`);
25
+ export function printNoTargetProjectMapped(stderr, sourceProjectName, projectId, envName, mappingsPath, command) {
26
+ if (command === "promote") {
27
+ stderr(`${FAILURE_ICON} ${ansis.bold(`"${sourceProjectName}" (${projectId})`)} has no target project mapped for "${ansis.bold(envName)}".`);
28
+ stderr(`Fill in target_project_id in ${mappingsPath}, or re-run with ${ansis.bold("--auto-create-projects")}.`);
29
+ return;
30
+ }
31
+ stderr("");
32
+ stderr("Your source environment is missing the following resolution:");
33
+ stderr(`${FAILURE_ICON} Project ${ansis.bold(`"${sourceProjectName}" (${projectId})`)} has no target project mapped for "${ansis.bold(envName)}".`);
34
+ stderr(`Fill in ${ansis.bold("target_project_id")} in ${mappingsPath}.`);
35
+ stderr(`${INFO_ICON} Run \`${ansis.bold(`tray env discover ${envName}`)}\` to see available target projects.`);
28
36
  }
29
37
  export function printInvalidAuthMappings(stderr, projectId, sourceProjectName, invalidAuthMappings) {
30
38
  printIssueBlock(stderr, `Project ${ansis.bold(`${projectId} (${sourceProjectName})`)} has unresolved authentication mappings:`, invalidAuthMappings.map((invalid) => `${ansis.bold(invalid.authExportId)}: "${invalid.value}" is not a valid target authentication id`), "Fill in a real target authentication id for each entry above, then retry.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trayai/tray-sync-cli",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
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"