@catladder/cli 0.0.0-next-71c49b28 → 0.0.0-next-5dc1f907

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.
Files changed (85) hide show
  1. package/dist/bundles/catenv/index.js +75 -12
  2. package/dist/bundles/cli/index.js +847 -51
  3. package/dist/cli/src/apps/cli/commands/project/doctor/DoctorReport.d.ts +22 -0
  4. package/dist/cli/src/apps/cli/commands/project/doctor/DoctorReport.js +75 -0
  5. package/dist/cli/src/apps/cli/commands/project/doctor/DoctorReport.js.map +1 -0
  6. package/dist/cli/src/apps/cli/commands/project/doctor/GcloudProjectInspector.d.ts +15 -0
  7. package/dist/cli/src/apps/cli/commands/project/doctor/GcloudProjectInspector.js +59 -0
  8. package/dist/cli/src/apps/cli/commands/project/doctor/GcloudProjectInspector.js.map +1 -0
  9. package/dist/cli/src/apps/cli/commands/project/doctor/checkCloudRun.d.ts +13 -0
  10. package/dist/cli/src/apps/cli/commands/project/doctor/checkCloudRun.js +120 -0
  11. package/dist/cli/src/apps/cli/commands/project/doctor/checkCloudRun.js.map +1 -0
  12. package/dist/cli/src/apps/cli/commands/project/doctor/checkGithub.d.ts +10 -0
  13. package/dist/cli/src/apps/cli/commands/project/doctor/checkGithub.js +100 -0
  14. package/dist/cli/src/apps/cli/commands/project/doctor/checkGithub.js.map +1 -0
  15. package/dist/cli/src/apps/cli/commands/project/doctor/checkGitlab.d.ts +13 -0
  16. package/dist/cli/src/apps/cli/commands/project/doctor/checkGitlab.js +79 -0
  17. package/dist/cli/src/apps/cli/commands/project/doctor/checkGitlab.js.map +1 -0
  18. package/dist/cli/src/apps/cli/commands/project/doctor/checkStore.d.ts +8 -0
  19. package/dist/cli/src/apps/cli/commands/project/doctor/checkStore.js +44 -0
  20. package/dist/cli/src/apps/cli/commands/project/doctor/checkStore.js.map +1 -0
  21. package/dist/cli/src/apps/cli/commands/project/doctor/index.d.ts +14 -0
  22. package/dist/cli/src/apps/cli/commands/project/doctor/index.js +48 -0
  23. package/dist/cli/src/apps/cli/commands/project/doctor/index.js.map +1 -0
  24. package/dist/cli/src/apps/cli/commands/project/setup/setupAccessTokens.js +4 -1
  25. package/dist/cli/src/apps/cli/commands/project/setup/setupAccessTokens.js.map +1 -1
  26. package/dist/cli/src/apps/cli/commands/project/setup/setupCloudRun.d.ts +21 -0
  27. package/dist/cli/src/apps/cli/commands/project/setup/setupCloudRun.js +31 -11
  28. package/dist/cli/src/apps/cli/commands/project/setup/setupCloudRun.js.map +1 -1
  29. package/dist/cli/src/commands/index.d.ts +1 -0
  30. package/dist/cli/src/commands/index.js +8 -1
  31. package/dist/cli/src/commands/index.js.map +1 -1
  32. package/dist/cli/src/commands/project/commandDoctor.d.ts +8 -0
  33. package/dist/cli/src/commands/project/commandDoctor.js +25 -0
  34. package/dist/cli/src/commands/project/commandDoctor.js.map +1 -0
  35. package/dist/cli/src/gcloud/artifactsRegistry.d.ts +1 -0
  36. package/dist/cli/src/gcloud/artifactsRegistry.js +3 -2
  37. package/dist/cli/src/gcloud/artifactsRegistry.js.map +1 -1
  38. package/dist/cli/src/gcloud/serviceAccountNames.d.ts +28 -0
  39. package/dist/cli/src/gcloud/serviceAccountNames.js +51 -0
  40. package/dist/cli/src/gcloud/serviceAccountNames.js.map +1 -0
  41. package/dist/cli/src/gcloud/serviceAccounts.js +6 -27
  42. package/dist/cli/src/gcloud/serviceAccounts.js.map +1 -1
  43. package/dist/cli/src/utils/concurrency.d.ts +7 -0
  44. package/dist/cli/src/utils/concurrency.js +27 -0
  45. package/dist/cli/src/utils/concurrency.js.map +1 -0
  46. package/dist/cli/src/utils/github.d.ts +10 -0
  47. package/dist/cli/src/utils/github.js +26 -1
  48. package/dist/cli/src/utils/github.js.map +1 -1
  49. package/dist/cli/src/utils/gitlab.d.ts +6 -0
  50. package/dist/cli/src/utils/gitlab.js +19 -1
  51. package/dist/cli/src/utils/gitlab.js.map +1 -1
  52. package/dist/pipeline/src/backends/github/createGithubJobs.js +24 -2
  53. package/dist/pipeline/src/backends/github/createGithubJobs.js.map +1 -1
  54. package/dist/pipeline/src/backends/gitlab/createGitlabJobs.js +8 -7
  55. package/dist/pipeline/src/backends/gitlab/createGitlabJobs.js.map +1 -1
  56. package/dist/pipeline/src/build/cache/createJobCache.js +8 -0
  57. package/dist/pipeline/src/build/cache/createJobCache.js.map +1 -1
  58. package/dist/pipeline/src/build/node/cache.js +15 -1
  59. package/dist/pipeline/src/build/node/cache.js.map +1 -1
  60. package/dist/pipeline/src/build/types.d.ts +19 -0
  61. package/dist/pipeline/src/build/types.js.map +1 -1
  62. package/dist/pipeline/src/constants.js +1 -1
  63. package/dist/pipeline/src/types/jobs.d.ts +6 -0
  64. package/dist/pipeline/src/types/jobs.js.map +1 -1
  65. package/dist/tsconfig.tsbuildinfo +1 -1
  66. package/package.json +1 -1
  67. package/src/apps/cli/commands/project/doctor/DoctorReport.ts +72 -0
  68. package/src/apps/cli/commands/project/doctor/GcloudProjectInspector.ts +77 -0
  69. package/src/apps/cli/commands/project/doctor/checkCloudRun.ts +148 -0
  70. package/src/apps/cli/commands/project/doctor/checkGithub.ts +156 -0
  71. package/src/apps/cli/commands/project/doctor/checkGitlab.ts +133 -0
  72. package/src/apps/cli/commands/project/doctor/checkStore.ts +43 -0
  73. package/src/apps/cli/commands/project/doctor/index.ts +59 -0
  74. package/src/apps/cli/commands/project/setup/setupAccessTokens.ts +9 -2
  75. package/src/apps/cli/commands/project/setup/setupCloudRun.ts +42 -21
  76. package/src/commands/index.ts +1 -0
  77. package/src/commands/project/commandDoctor.ts +21 -0
  78. package/src/gcloud/__tests__/serviceAccountNames.test.ts +63 -0
  79. package/src/gcloud/artifactsRegistry.ts +3 -1
  80. package/src/gcloud/serviceAccountNames.ts +65 -0
  81. package/src/gcloud/serviceAccounts.ts +6 -38
  82. package/src/utils/concurrency.ts +25 -0
  83. package/src/utils/github.ts +48 -0
  84. package/src/utils/gitlab.ts +24 -0
  85. package/tsconfig.json +2 -1
@@ -1,13 +1,20 @@
1
1
  import type { IO } from "../../../../../core/types";
2
2
  import { add, format } from "date-fns";
3
- import { doGitlabRequest, getProjectInfo } from "../../../../../utils/gitlab";
3
+ import {
4
+ doGitlabRequest,
5
+ doGitlabRequestAllPages,
6
+ getProjectInfo,
7
+ } from "../../../../../utils/gitlab";
4
8
 
5
9
  const TOKEN_NAME = "semantic-release";
6
10
 
7
11
  export const setupAccessTokens = async (instance: IO) => {
8
12
  const { id: projectId } = await getProjectInfo(instance);
9
13
 
10
- const projectTokens = await doGitlabRequest(
14
+ // all pages: rotated tokens accumulate one (inactive) entry per
15
+ // rotation, so the active one quickly moves past the first page —
16
+ // missing it here created a brand-new duplicate token on every setup
17
+ const projectTokens = await doGitlabRequestAllPages(
11
18
  instance,
12
19
  `projects/${projectId}/access_tokens`,
13
20
  );
@@ -11,6 +11,45 @@ import { fetchGcloudProjectNumber } from "../../../../../gcloud/getProjectNumber
11
11
  import { upsertGcloudServiceAccountAndSaveSecret } from "../../../../../gcloud/serviceAccounts";
12
12
  import { updateCatladderStore } from "../../../../../store/updateCatladderStore";
13
13
 
14
+ export const CLOUD_RUN_DEPLOY_SA_NAME = "cl-d";
15
+
16
+ /**
17
+ * the gcloud services the given deploy config requires.
18
+ *
19
+ * Shared with `project doctor`: setup enables them, doctor compares
20
+ * them against what is actually enabled — config edits after setup
21
+ * (e.g. adding `cloudSql`) would otherwise drift silently.
22
+ */
23
+ export const getCloudRunRequiredServices = (config: {
24
+ cloudSql?: unknown;
25
+ }): string[] => [
26
+ "run.googleapis.com",
27
+ "artifactregistry.googleapis.com",
28
+ "cloudscheduler.googleapis.com", // for scheduling jobs
29
+ "cloudresourcemanager.googleapis.com", // only required to get google cloud project number
30
+ ...(config.cloudSql
31
+ ? ["sqladmin.googleapis.com", "sql-component.googleapis.com"]
32
+ : []),
33
+ ];
34
+
35
+ /**
36
+ * the IAM roles the deploy service account needs for the given deploy
37
+ * config. Shared with `project doctor` (same rationale as
38
+ * {@link getCloudRunRequiredServices}) — the motivating case: a
39
+ * component gets `cloudSql` after its setup ran, deploys keep working,
40
+ * and only the review teardown hits the missing `cloudsql.admin`.
41
+ */
42
+ export const getCloudRunDeployRoles = (config: {
43
+ cloudSql?: unknown;
44
+ }): string[] => [
45
+ // TODO: run.admin is a bit much, would be better to follow https://stackoverflow.com/questions/55788714/deploying-to-cloud-run-with-a-custom-service-account-failed-with-iam-serviceacco
46
+ "roles/artifactregistry.repoAdmin",
47
+ "roles/run.admin",
48
+ "roles/iam.serviceAccountUser",
49
+ "roles/cloudscheduler.admin", // for scheduling
50
+ ...(config.cloudSql ? ["roles/cloudsql.admin"] : []),
51
+ ];
52
+
14
53
  export const setupCloudRun = async (
15
54
  instance: IO,
16
55
  context: ComponentContext,
@@ -24,18 +63,7 @@ export const setupCloudRun = async (
24
63
  // enable services
25
64
 
26
65
  instance.log("enable required services...");
27
- await enableGCloudServices(
28
- [
29
- "run.googleapis.com",
30
- "artifactregistry.googleapis.com",
31
- "cloudscheduler.googleapis.com", // for scheduling jobs
32
- "cloudresourcemanager.googleapis.com", // only required to get google cloud project number
33
- ...(config.cloudSql
34
- ? ["sqladmin.googleapis.com", "sql-component.googleapis.com"]
35
- : []),
36
- ],
37
- config,
38
- );
66
+ await enableGCloudServices(getCloudRunRequiredServices(config), config);
39
67
  instance.log("upsert artifacts registry...");
40
68
  await upsertGcloudArtifactsRegistry(config);
41
69
 
@@ -47,15 +75,8 @@ export const setupCloudRun = async (
47
75
  projectId: config.projectId,
48
76
  displayName: "Catladder gcloud deploy",
49
77
  description: "This service account deploys to google cloud",
50
- name: "cl-d",
51
- // TODO: run.admin is a bit much, would be better to follow https://stackoverflow.com/questions/55788714/deploying-to-cloud-run-with-a-custom-service-account-failed-with-iam-serviceacco
52
- roles: [
53
- "roles/artifactregistry.repoAdmin",
54
- "roles/run.admin",
55
- "roles/iam.serviceAccountUser",
56
- "roles/cloudscheduler.admin", // for scheduling
57
- ...(config.cloudSql ? ["roles/cloudsql.admin"] : []),
58
- ],
78
+ name: CLOUD_RUN_DEPLOY_SA_NAME,
79
+ roles: getCloudRunDeployRoles(config),
59
80
  },
60
81
  GCLOUD_DEPLOY_CREDENTIALS_KEY,
61
82
  );
@@ -21,6 +21,7 @@ export { commandTriggerCronjobGeneral } from "./project/k8s/commandTriggerCronjo
21
21
  export { commandRenewToken } from "./project/commandRenewToken";
22
22
  export { commandGetMyTotalWorktime } from "./project/commandGetMyTotalWorktime";
23
23
  export { commandSetup } from "./project/commandSetup";
24
+ export { commandDoctor } from "./project/commandDoctor";
24
25
  export { commandCiJobOpen, commandCiJobLog } from "./project/commandGitlabCi";
25
26
  export { commandProjectRestoreDb } from "./project/commandProjectRestoreDb";
26
27
  export { commandSecurityEvaluate } from "./project/commandSecurityEvaluate";
@@ -0,0 +1,21 @@
1
+ import { doctorProject } from "../../apps/cli/commands/project/doctor";
2
+ import { defineCommand } from "../../core/defineCommand";
3
+
4
+ export const commandDoctor = defineCommand({
5
+ name: "project doctor",
6
+ description:
7
+ "Checks for drift between the config and the provisioned infrastructure (read-only): service accounts, IAM roles, gitlab tokens, github secrets, store entries.",
8
+ group: "project",
9
+ inputs: {
10
+ component: {
11
+ type: "string",
12
+ message: "component name",
13
+ positional: true,
14
+ required: false,
15
+ },
16
+ },
17
+ execute: async (ctx) => {
18
+ const component = await ctx.get("component");
19
+ await doctorProject(ctx, component);
20
+ },
21
+ });
@@ -0,0 +1,63 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { getGcloudServiceAccountNames } from "../serviceAccountNames";
3
+
4
+ // the naming is shared between `project setup` (creates accounts) and
5
+ // `project doctor` (computes the expected identifier) — these tests pin
6
+ // it down so a change never silently makes doctor look for accounts
7
+ // under a different name than setup created them
8
+
9
+ const account = { name: "cl-d", projectId: "some-project" };
10
+
11
+ describe("getGcloudServiceAccountNames", () => {
12
+ it("keeps all parts verbatim when they fit into 30 chars", () => {
13
+ const { fullName, fullIdentifier } = getGcloudServiceAccountNames(
14
+ {
15
+ env: "dev",
16
+ name: "www",
17
+ fullConfig: { customerName: "acme", appName: "shop" },
18
+ },
19
+ account,
20
+ );
21
+ expect(fullName).toBe("cl-d-acme-shop-dev-www");
22
+ expect(fullIdentifier).toBe(
23
+ "cl-d-acme-shop-dev-www@some-project.iam.gserviceaccount.com",
24
+ );
25
+ });
26
+
27
+ it("hashes the customer/app middle part when the name would exceed 30 chars", () => {
28
+ const { fullName } = getGcloudServiceAccountNames(
29
+ {
30
+ env: "review",
31
+ name: "outlet-stats",
32
+ fullConfig: { customerName: "wea", appName: "food2050" },
33
+ },
34
+ account,
35
+ );
36
+ expect(fullName).toMatch(/^cl-d-[0-9a-f]{5}-review-outlet-stats$/);
37
+ expect(fullName.length).toBe(30);
38
+ });
39
+
40
+ it("hashes the env/component suffix too when a 1-char middle still does not fit", () => {
41
+ const { fullName } = getGcloudServiceAccountNames(
42
+ {
43
+ env: "review",
44
+ name: "a-component-with-a-very-long-name",
45
+ fullConfig: { customerName: "customer", appName: "app" },
46
+ },
47
+ account,
48
+ );
49
+ expect(fullName).toMatch(/^cl-d-[0-9a-f]-[0-9a-f]{23}$/);
50
+ expect(fullName.length).toBe(30);
51
+ });
52
+
53
+ it("is deterministic", () => {
54
+ const context = {
55
+ env: "review",
56
+ name: "outlet-stats",
57
+ fullConfig: { customerName: "wea", appName: "food2050" },
58
+ };
59
+ expect(getGcloudServiceAccountNames(context, account)).toEqual(
60
+ getGcloudServiceAccountNames(context, account),
61
+ );
62
+ });
63
+ });
@@ -1,13 +1,15 @@
1
1
  import type { Gcloudregion } from "@catladder/pipeline";
2
2
  import { exec } from "child-process-promise";
3
3
 
4
+ export const CATLADDER_REGISTRY_NAME = "catladder-deploy";
5
+
4
6
  export const upsertGcloudArtifactsRegistry = async (config: {
5
7
  projectId: string;
6
8
  region: Gcloudregion;
7
9
  }) => {
8
10
  try {
9
11
  await exec(
10
- `gcloud artifacts repositories create catladder-deploy --project="${config.projectId}" --repository-format=docker --location=${config.region}`,
12
+ `gcloud artifacts repositories create ${CATLADDER_REGISTRY_NAME} --project="${config.projectId}" --repository-format=docker --location=${config.region}`,
11
13
  );
12
14
  } catch (e) {
13
15
  // probably already exists
@@ -0,0 +1,65 @@
1
+ import { createHmac } from "crypto";
2
+
3
+ /**
4
+ * the parts of a component context the naming depends on — structural
5
+ * subset of `ComponentContext` so the naming stays a pure function
6
+ */
7
+ export type ServiceAccountNamingContext = {
8
+ env: string;
9
+ name: string;
10
+ fullConfig: { customerName: string; appName: string };
11
+ };
12
+
13
+ /**
14
+ * the deterministic gcloud service account naming used by
15
+ * `project setup`. Extracted so `project doctor` can compute the
16
+ * expected identifier of an account without creating anything.
17
+ *
18
+ * gcloud limits account ids to 30 chars: the customer/app middle part
19
+ * (and, if even that is not enough, the env/component suffix) is
20
+ * replaced by a hash when the full name would not fit.
21
+ */
22
+ export const getGcloudServiceAccountNames = (
23
+ context: ServiceAccountNamingContext,
24
+ account: { name: string; projectId: string },
25
+ ): { fullName: string; fullIdentifier: string } => {
26
+ const { projectId, name } = account;
27
+
28
+ // name has limit of 30
29
+ const namePrefix = `${name}`;
30
+ const nameSuffixRaw = `${context.env}-${context.name}`;
31
+ const nameMiddleRaw = `${context.fullConfig.customerName}-${context.fullConfig.appName}`;
32
+ const MAX_LENGTH = 30;
33
+ const NUM_SEPARATORS = 2;
34
+
35
+ // we want to first hash middle, then suffix
36
+ // if for middle we have at least 1 char left, its ok, so we don't hash nameSuffix, otherwise we need to hash that as well
37
+
38
+ const middleMaxLength =
39
+ MAX_LENGTH - namePrefix.length - nameSuffixRaw.length - NUM_SEPARATORS;
40
+
41
+ let nameMiddle: string;
42
+ let nameSuffix: string;
43
+ if (middleMaxLength < 1) {
44
+ nameMiddle = hashIfNessecary(nameMiddleRaw, 1);
45
+ nameSuffix = hashIfNessecary(
46
+ nameSuffixRaw,
47
+ MAX_LENGTH - namePrefix.length - 1 - NUM_SEPARATORS,
48
+ );
49
+ } else {
50
+ nameMiddle = hashIfNessecary(nameMiddleRaw, middleMaxLength);
51
+ nameSuffix = nameSuffixRaw;
52
+ }
53
+
54
+ const fullName = `${namePrefix}-${nameMiddle}-${nameSuffix}`;
55
+ const fullIdentifier = `${fullName}@${projectId}.iam.gserviceaccount.com`;
56
+
57
+ return { fullName, fullIdentifier };
58
+ };
59
+
60
+ const hashIfNessecary = (str: string, maxLength: number) =>
61
+ str.length > maxLength ? hash(str, maxLength) : str;
62
+
63
+ const hash = (str: string, length: number) => {
64
+ return createHmac("sha256", str).digest("hex").substring(0, length);
65
+ };
@@ -1,9 +1,9 @@
1
1
  import type { ComponentContext } from "@catladder/pipeline";
2
2
 
3
3
  import { exec } from "child-process-promise";
4
- import { createHmac } from "crypto";
5
4
  import type { IO } from "../core/types";
6
5
  import { upsertAllVariables } from "../utils/gitlab";
6
+ import { getGcloudServiceAccountNames } from "./serviceAccountNames";
7
7
 
8
8
  export const accountExists = async (fullIdentifier: string) => {
9
9
  try {
@@ -25,40 +25,15 @@ const upsertGcloudServiceAccount = async (
25
25
  context: ComponentContext,
26
26
  account: ServiceAccount,
27
27
  ): Promise<string> => {
28
- const { projectId, name, displayName, roles, description } = account;
28
+ const { projectId, displayName, roles, description } = account;
29
29
 
30
- // name has limit of 30
31
- const namePrefix = `${name}`;
32
- const nameSuffixRaw = `${context.env}-${context.name}`;
33
- const nameMiddleRaw = `${context.fullConfig.customerName}-${context.fullConfig.appName}`;
34
- const MAX_LENGTH = 30;
35
- const NUM_SEPARATORS = 2;
36
-
37
- // we want to first hash middle, then suffix
38
- // if for middle we have at least 1 char left, its ok, so we don't hash nameSuffix, otherwise we need to hash that as well
39
-
40
- const middleMaxLength =
41
- MAX_LENGTH - namePrefix.length - nameSuffixRaw.length - NUM_SEPARATORS;
42
-
43
- let nameMiddle: string;
44
- let nameSuffix: string;
45
- if (middleMaxLength < 1) {
46
- nameMiddle = hashIfNessecary(nameMiddleRaw, 1);
47
- nameSuffix = hashIfNessecary(
48
- nameSuffixRaw,
49
- MAX_LENGTH - namePrefix.length - 1 - NUM_SEPARATORS,
50
- );
51
- } else {
52
- nameMiddle = hashIfNessecary(nameMiddleRaw, middleMaxLength);
53
- nameSuffix = nameSuffixRaw;
54
- }
55
-
56
- const fullName = `${namePrefix}-${nameMiddle}-${nameSuffix}`;
30
+ const { fullName, fullIdentifier } = getGcloudServiceAccountNames(
31
+ context,
32
+ account,
33
+ );
57
34
 
58
35
  const fullDisplayName = `${context.fullConfig.customerName}-${context.fullConfig.appName} ${context.env}:${context.name} | ${displayName}`;
59
36
 
60
- const fullIdentifier = `${fullName}@${projectId}.iam.gserviceaccount.com`;
61
-
62
37
  const existing = await accountExists(fullIdentifier);
63
38
 
64
39
  if (!existing) {
@@ -111,10 +86,3 @@ export const upsertGcloudServiceAccountAndSaveSecret = async (
111
86
  );
112
87
  instance.log("done!");
113
88
  };
114
-
115
- const hashIfNessecary = (str: string, maxLength: number) =>
116
- str.length > maxLength ? hash(str, maxLength) : str;
117
-
118
- const hash = (str: string, length: number) => {
119
- return createHmac("sha256", str).digest("hex").substring(0, length);
120
- };
@@ -0,0 +1,25 @@
1
+ /**
2
+ * maps over items with at most `limit` calls in flight, preserving the
3
+ * input order in the result (unlike reporting-as-you-go, this lets
4
+ * callers run slow per-item lookups in parallel and still log the
5
+ * outcomes in a stable order)
6
+ */
7
+ export const mapWithConcurrency = async <T, R>(
8
+ items: T[],
9
+ limit: number,
10
+ fn: (item: T, index: number) => Promise<R>,
11
+ ): Promise<R[]> => {
12
+ const results = new Array<R>(items.length);
13
+ let next = 0;
14
+ const workers = Array.from(
15
+ { length: Math.max(1, Math.min(limit, items.length)) },
16
+ async () => {
17
+ while (next < items.length) {
18
+ const index = next++;
19
+ results[index] = await fn(items[index], index);
20
+ }
21
+ },
22
+ );
23
+ await Promise.all(workers);
24
+ return results;
25
+ };
@@ -34,6 +34,54 @@ export const getGithubRepoFromRemote = async (
34
34
  }
35
35
  };
36
36
 
37
+ /**
38
+ * runs a paginated `gh api` query and returns the jq-extracted lines
39
+ * (one value per line, e.g. names)
40
+ */
41
+ const ghApiLines = async (path: string, jq: string): Promise<string[]> => {
42
+ const { stdout } = await execFile(
43
+ "gh",
44
+ ["api", "--paginate", path, "--jq", jq],
45
+ { maxBuffer: 10 * 1024 * 1024 },
46
+ );
47
+ return stdout
48
+ .split("\n")
49
+ .map((line) => line.trim())
50
+ .filter(Boolean);
51
+ };
52
+
53
+ /** names of all deployment environments of the repository */
54
+ export const listGithubEnvironments = (repo: string): Promise<string[]> =>
55
+ ghApiLines(`repos/${repo}/environments?per_page=100`, ".environments[].name");
56
+
57
+ /**
58
+ * names of the actions secrets — of an environment, or of the repo
59
+ * level when no environment is given. Github secrets are write-only:
60
+ * names are all that can ever be inspected.
61
+ */
62
+ export const listGithubSecretNames = (
63
+ repo: string,
64
+ environment?: string,
65
+ ): Promise<string[]> =>
66
+ ghApiLines(
67
+ environment
68
+ ? `repos/${repo}/environments/${encodeURIComponent(environment)}/secrets?per_page=100`
69
+ : `repos/${repo}/actions/secrets?per_page=100`,
70
+ ".secrets[].name",
71
+ );
72
+
73
+ /** names of the actions variables (environment or repo level) */
74
+ export const listGithubVariableNames = (
75
+ repo: string,
76
+ environment?: string,
77
+ ): Promise<string[]> =>
78
+ ghApiLines(
79
+ environment
80
+ ? `repos/${repo}/environments/${encodeURIComponent(environment)}/variables?per_page=100`
81
+ : `repos/${repo}/actions/variables?per_page=100`,
82
+ ".variables[].name",
83
+ );
84
+
37
85
  const ghSetViaStdin = (
38
86
  kind: "secret" | "variable",
39
87
  repo: string,
@@ -90,6 +90,30 @@ export const doGitlabRequest = async <T = any>(
90
90
  );
91
91
  };
92
92
 
93
+ /**
94
+ * GET a list endpoint across ALL pages (gitlab returns 20 items per
95
+ * page by default — long-lived projects easily exceed that, e.g.
96
+ * rotated access tokens accumulate one entry per rotation)
97
+ */
98
+ export const doGitlabRequestAllPages = async <T = any>(
99
+ io: IO | null,
100
+ path: string,
101
+ ): Promise<T[]> => {
102
+ const PER_PAGE = 100;
103
+ const separator = path.includes("?") ? "&" : "?";
104
+ const all: T[] = [];
105
+ for (let page = 1; ; page++) {
106
+ const items = await doGitlabRequest<T[]>(
107
+ io,
108
+ `${path}${separator}per_page=${PER_PAGE}&page=${page}`,
109
+ );
110
+ if (!items?.length) break;
111
+ all.push(...items);
112
+ if (items.length < PER_PAGE) break;
113
+ }
114
+ return all;
115
+ };
116
+
93
117
  export const getProjectInfo = async (
94
118
  io: IO | null,
95
119
  ): Promise<{ id: string; web_url: string }> => {
package/tsconfig.json CHANGED
@@ -16,5 +16,6 @@
16
16
  "@catladder/pipeline": ["../pipeline/src/index.ts"]
17
17
  }
18
18
  },
19
- "include": ["./src/**/*"]
19
+ "include": ["./src/**/*"],
20
+ "exclude": ["**/__tests__", "**/*.test.ts", "**/*.test.tsx"]
20
21
  }