@credibledata/cred-cli 0.0.102 → 0.0.103

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 (118) hide show
  1. package/README.md +1 -1
  2. package/coverage/coverage-summary.json +40 -0
  3. package/coverage/tmp/coverage-2152-1789496974457-0.json +1 -0
  4. package/coverage/tmp/coverage-2153-1789496954404-0.json +1 -0
  5. package/dist/client/api.js +4193 -1342
  6. package/dist/client/api.js.map +1 -1
  7. package/dist/clientconfiguration.js +57 -4
  8. package/dist/clientconfiguration.js.map +1 -1
  9. package/dist/clientconfiguration.spec.js +90 -0
  10. package/dist/clientconfiguration.spec.js.map +1 -1
  11. package/dist/commands/add.js +18 -0
  12. package/dist/commands/add.js.map +1 -1
  13. package/dist/commands/environment/updateenvironment.js +63 -1
  14. package/dist/commands/environment/updateenvironment.js.map +1 -1
  15. package/dist/commands/environment/updateenvironment.spec.js +84 -0
  16. package/dist/commands/environment/updateenvironment.spec.js.map +1 -1
  17. package/dist/commands/get.js +52 -1
  18. package/dist/commands/get.js.map +1 -1
  19. package/dist/commands/list.js +78 -0
  20. package/dist/commands/list.js.map +1 -1
  21. package/dist/commands/materialization/cancelrun.js +62 -0
  22. package/dist/commands/materialization/cancelrun.js.map +1 -0
  23. package/dist/commands/materialization/getindex.js +52 -0
  24. package/dist/commands/materialization/getindex.js.map +1 -0
  25. package/dist/commands/materialization/getmaterialization.js +54 -0
  26. package/dist/commands/materialization/getmaterialization.js.map +1 -0
  27. package/dist/commands/materialization/getpreaggregate.js +55 -0
  28. package/dist/commands/materialization/getpreaggregate.js.map +1 -0
  29. package/dist/commands/materialization/getrun.js +54 -0
  30. package/dist/commands/materialization/getrun.js.map +1 -0
  31. package/dist/commands/materialization/listindexes.js +58 -0
  32. package/dist/commands/materialization/listindexes.js.map +1 -0
  33. package/dist/commands/materialization/listmaterializations.js +52 -0
  34. package/dist/commands/materialization/listmaterializations.js.map +1 -0
  35. package/dist/commands/materialization/listpreaggregates.js +61 -0
  36. package/dist/commands/materialization/listpreaggregates.js.map +1 -0
  37. package/dist/commands/materialization/listruns.js +60 -0
  38. package/dist/commands/materialization/listruns.js.map +1 -0
  39. package/dist/commands/materialization/rerun.js +111 -0
  40. package/dist/commands/materialization/rerun.js.map +1 -0
  41. package/dist/commands/misc/login.js +14 -0
  42. package/dist/commands/misc/login.js.map +1 -1
  43. package/dist/commands/misc/login.spec.js +92 -0
  44. package/dist/commands/misc/login.spec.js.map +1 -1
  45. package/dist/commands/package/listpackages.js +7 -0
  46. package/dist/commands/package/listpackages.js.map +1 -1
  47. package/dist/commands/package/listpackages.spec.js +12 -0
  48. package/dist/commands/package/listpackages.spec.js.map +1 -1
  49. package/dist/commands/package/publish.js +34 -11
  50. package/dist/commands/package/publish.js.map +1 -1
  51. package/dist/commands/package/publish.spec.js +26 -28
  52. package/dist/commands/package/publish.spec.js.map +1 -1
  53. package/dist/commands/permission/createpermission.js +123 -0
  54. package/dist/commands/permission/createpermission.js.map +1 -0
  55. package/dist/commands/permission/createpermission.spec.js +360 -0
  56. package/dist/commands/permission/createpermission.spec.js.map +1 -0
  57. package/dist/commands/permission/deletepermission.js +86 -0
  58. package/dist/commands/permission/deletepermission.js.map +1 -0
  59. package/dist/commands/permission/getpermission.js +85 -0
  60. package/dist/commands/permission/getpermission.js.map +1 -0
  61. package/dist/commands/permission/listpermissions.js +75 -0
  62. package/dist/commands/permission/listpermissions.js.map +1 -0
  63. package/dist/commands/permission/scope.js +140 -0
  64. package/dist/commands/permission/scope.js.map +1 -0
  65. package/dist/commands/rm.js +11 -0
  66. package/dist/commands/rm.js.map +1 -1
  67. package/dist/commands/update.js +18 -12
  68. package/dist/commands/update.js.map +1 -1
  69. package/dist/common/UserGroupId.js +1 -1
  70. package/dist/common/UserGroupId.js.map +1 -1
  71. package/dist/common/UserGroupId.spec.js +2 -4
  72. package/dist/common/UserGroupId.spec.js.map +1 -1
  73. package/dist/constants.js +48 -0
  74. package/dist/constants.js.map +1 -1
  75. package/dist/index.js +61 -6
  76. package/dist/index.js.map +1 -1
  77. package/dist/logger.js +5 -1
  78. package/dist/logger.js.map +1 -1
  79. package/openapitools.json +4 -1
  80. package/package.json +5 -3
  81. package/src/client/api.ts +6433 -1533
  82. package/src/clientconfiguration.spec.ts +126 -0
  83. package/src/clientconfiguration.ts +64 -4
  84. package/src/commands/add.ts +43 -0
  85. package/src/commands/environment/updateenvironment.spec.ts +167 -0
  86. package/src/commands/environment/updateenvironment.ts +85 -1
  87. package/src/commands/get.ts +110 -1
  88. package/src/commands/list.ts +171 -0
  89. package/src/commands/materialization/cancelrun.ts +70 -0
  90. package/src/commands/materialization/getindex.ts +55 -0
  91. package/src/commands/materialization/getmaterialization.ts +57 -0
  92. package/src/commands/materialization/getpreaggregate.ts +58 -0
  93. package/src/commands/materialization/getrun.ts +55 -0
  94. package/src/commands/materialization/listindexes.ts +69 -0
  95. package/src/commands/materialization/listmaterializations.ts +53 -0
  96. package/src/commands/materialization/listpreaggregates.ts +74 -0
  97. package/src/commands/materialization/listruns.ts +77 -0
  98. package/src/commands/materialization/rerun.ts +123 -0
  99. package/src/commands/misc/login.spec.ts +118 -2
  100. package/src/commands/misc/login.ts +14 -0
  101. package/src/commands/package/listpackages.spec.ts +16 -0
  102. package/src/commands/package/listpackages.ts +7 -0
  103. package/src/commands/package/publish.spec.ts +45 -30
  104. package/src/commands/package/publish.ts +31 -10
  105. package/src/commands/permission/createpermission.spec.ts +507 -0
  106. package/src/commands/permission/createpermission.ts +181 -0
  107. package/src/commands/permission/deletepermission.ts +117 -0
  108. package/src/commands/permission/getpermission.ts +115 -0
  109. package/src/commands/permission/listpermissions.ts +101 -0
  110. package/src/commands/permission/scope.ts +180 -0
  111. package/src/commands/rm.ts +29 -0
  112. package/src/commands/update.ts +50 -27
  113. package/src/common/UserGroupId.spec.ts +2 -4
  114. package/src/common/UserGroupId.ts +1 -1
  115. package/src/constants.ts +50 -1
  116. package/src/index.ts +94 -11
  117. package/src/logger.ts +5 -1
  118. package/bun.lock +0 -934
@@ -313,6 +313,74 @@ describe("ClientConfiguration", () => {
313
313
  expect(writeFileSyncStub.called).to.be.false;
314
314
  });
315
315
 
316
+ describe("recoverIfCorrupt", () => {
317
+ it("returns null and writes nothing when the config is already valid", () => {
318
+ readFileSyncStub.returns(
319
+ JSON.stringify({
320
+ configPerEnvironment: {},
321
+ activeEnvironment: "staging",
322
+ }),
323
+ );
324
+
325
+ expect(clientConfig.recoverIfCorrupt()).to.be.null;
326
+ expect(renameSyncStub.called).to.be.false;
327
+ // Valid config preserved — active environment untouched.
328
+ expect(clientConfig.getEnvironment()).to.equal("staging");
329
+ });
330
+
331
+ it("backs up an unparseable file and resets to defaults", () => {
332
+ readFileSyncStub.returns("{ not json");
333
+
334
+ const backupPath = clientConfig.recoverIfCorrupt();
335
+
336
+ expect(backupPath).to.match(
337
+ new RegExp(`${customConfigPath}\\.corrupt-[0-9a-f]{16}$`),
338
+ );
339
+ // Renamed aside, never deleted, so the bad file is recoverable.
340
+ expect(renameSyncStub.calledWith(customConfigPath, backupPath!)).to.be
341
+ .true;
342
+ expect(lastWrittenConfig()).to.deep.equal({
343
+ configPerEnvironment: fullPerEnv(),
344
+ activeEnvironment: "production",
345
+ });
346
+ });
347
+
348
+ it("backs up a structurally-invalid file and resets to defaults", () => {
349
+ readFileSyncStub.returns(JSON.stringify({ configPerEnvironment: {} }));
350
+
351
+ const backupPath = clientConfig.recoverIfCorrupt();
352
+
353
+ expect(backupPath).to.be.a("string");
354
+ expect(renameSyncStub.calledWith(customConfigPath, backupPath!)).to.be
355
+ .true;
356
+ expect(lastWrittenConfig().activeEnvironment).to.equal("production");
357
+ });
358
+
359
+ it("rethrows fs errors (e.g. EACCES) instead of masking them as a reset", () => {
360
+ // A permissions problem must surface — resetting would just fail to
361
+ // write anyway, and silently "recovering" would hide the real cause.
362
+ const eacces = Object.assign(new Error("EACCES"), { code: "EACCES" });
363
+ readFileSyncStub.throws(eacces);
364
+
365
+ expect(() => clientConfig.recoverIfCorrupt()).to.throw(/EACCES/);
366
+ expect(renameSyncStub.called).to.be.false;
367
+ });
368
+
369
+ it("creates a fresh default when the file is absent (ENOENT)", () => {
370
+ readFileSyncStub.throws(enoent());
371
+
372
+ // First-time setup is not corruption — nothing to back up. (The atomic
373
+ // write of the new default does call renameSync, but never to a
374
+ // `.corrupt-` backup path.)
375
+ expect(clientConfig.recoverIfCorrupt()).to.be.null;
376
+ expect(
377
+ renameSyncStub
378
+ .getCalls()
379
+ .some((c) => String(c.args[1]).includes(".corrupt-")),
380
+ ).to.be.false;
381
+ });
382
+ });
383
+
316
384
  describe("isTokenValid", () => {
317
385
  it("should return invalid when no token exists", () => {
318
386
  const mockConfig = {
@@ -468,6 +536,64 @@ describe("ClientConfiguration", () => {
468
536
  .true;
469
537
  });
470
538
 
539
+ it("should name the targeted cell when the token is valid", () => {
540
+ const payload = { exp: Math.floor(Date.now() / 1000) + 3600 };
541
+ const fakeJwt =
542
+ Buffer.from(JSON.stringify({ alg: "HS256" })).toString(
543
+ "base64url",
544
+ ) +
545
+ "." +
546
+ Buffer.from(JSON.stringify(payload)).toString("base64url") +
547
+ ".signature";
548
+
549
+ const mockConfig = {
550
+ configPerEnvironment: {
551
+ production: {},
552
+ staging: {},
553
+ localhost: {},
554
+ dev: {
555
+ accessToken: fakeJwt,
556
+ isServiceAccountToken: false,
557
+ organization: "demo",
558
+ project: "demo",
559
+ },
560
+ },
561
+ activeEnvironment: "dev",
562
+ };
563
+ readFileSyncStub.returns(JSON.stringify(mockConfig));
564
+
565
+ clientConfig.printStatus();
566
+
567
+ expect(loggerStub.info.calledWith(sinon.match("Cell:"))).to.be.true;
568
+ expect(loggerStub.info.calledWith(sinon.match("dev"))).to.be.true;
569
+ expect(
570
+ loggerStub.info.calledWith(
571
+ sinon.match("https://dev-admin.credibledata.com/api/v0"),
572
+ ),
573
+ ).to.be.true;
574
+ });
575
+
576
+ it("should name the targeted cell even when the token is invalid", () => {
577
+ const mockConfig = {
578
+ configPerEnvironment: {
579
+ production: {},
580
+ staging: {},
581
+ localhost: {},
582
+ },
583
+ activeEnvironment: "localhost",
584
+ };
585
+ readFileSyncStub.returns(JSON.stringify(mockConfig));
586
+
587
+ clientConfig.printStatus();
588
+
589
+ expect(loggerStub.info.calledWith(sinon.match("Cell:"))).to.be.true;
590
+ expect(
591
+ loggerStub.info.calledWith(
592
+ sinon.match("http://admin.ms2.localhost/api/v0"),
593
+ ),
594
+ ).to.be.true;
595
+ });
596
+
471
597
  it("should print error when token is invalid", () => {
472
598
  const mockConfig = {
473
599
  configPerEnvironment: {
@@ -3,7 +3,12 @@ import fs from "fs";
3
3
  import { jwtDecode } from "jwt-decode";
4
4
  import kleur from "kleur";
5
5
  import os from "os";
6
- import { ALL_ENVIRONMENTS, ASCII_TITLE, Environment } from "./constants";
6
+ import {
7
+ ALL_ENVIRONMENTS,
8
+ ASCII_TITLE,
9
+ controlPlanesByEnv,
10
+ Environment,
11
+ } from "./constants";
7
12
  import { logger } from "./logger";
8
13
 
9
14
  interface ConfigFile {
@@ -34,6 +39,15 @@ interface JwtPayload {
34
39
  sub?: string;
35
40
  }
36
41
 
42
+ /**
43
+ * Thrown when `.cred` exists but cannot be used: either it does not parse as
44
+ * JSON or it parses but lacks the required top-level shape. Distinct from fs
45
+ * errors (ENOENT, EACCES) so callers that want to recover — `cred login` —
46
+ * can target *only* a corrupt-content failure and leave permission/IO errors
47
+ * to surface loudly.
48
+ */
49
+ export class InvalidConfigError extends Error {}
50
+
37
51
  export class ClientConfiguration {
38
52
  private configPath = `${os.homedir()}/.cred`;
39
53
  private config: ConfigFile;
@@ -65,13 +79,13 @@ export class ClientConfiguration {
65
79
  // can leave `.cred` unparseable. Fail loud rather than silently
66
80
  // overwriting — a silent reset erases the access token for every
67
81
  // subsequent caller on the same machine.
68
- throw new Error(
82
+ throw new InvalidConfigError(
69
83
  `Failed to parse ${this.configPath}: ${error}. The file may be corrupt; re-run \`cred login\` to reset.`,
70
84
  );
71
85
  }
72
86
 
73
87
  if (!this.config.activeEnvironment || !this.config.configPerEnvironment) {
74
- throw new Error(
88
+ throw new InvalidConfigError(
75
89
  `Invalid config structure in ${this.configPath}; re-run \`cred login\` to reset.`,
76
90
  );
77
91
  }
@@ -85,6 +99,46 @@ export class ClientConfiguration {
85
99
  return this.config;
86
100
  }
87
101
 
102
+ /**
103
+ * Ensure `.cred` is usable, recovering from a corrupt file if needed.
104
+ *
105
+ * `loadConfig` deliberately fails loud on a corrupt/invalid file so a read
106
+ * path never silently erases tokens. But `cred login` exists precisely to
107
+ * (re)establish config, so it must be able to get past a bad file — that is
108
+ * what the "re-run `cred login` to reset" message promises. We make the
109
+ * message true here: the bad file is renamed aside (never deleted, so a
110
+ * support engineer can still inspect it) and a fresh default is written.
111
+ *
112
+ * Only an {@link InvalidConfigError} triggers recovery. fs errors such as
113
+ * EACCES are real environment problems and are rethrown — resetting would
114
+ * just fail to write anyway, and we must not mask a permissions issue as a
115
+ * successful reset.
116
+ *
117
+ * @returns the path the corrupt file was backed up to, or null if the
118
+ * existing config was already valid.
119
+ */
120
+ public recoverIfCorrupt(): string | null {
121
+ try {
122
+ this.loadConfig();
123
+ return null;
124
+ } catch (error) {
125
+ if (!(error instanceof InvalidConfigError)) {
126
+ throw error;
127
+ }
128
+ const backupPath = `${this.configPath}.corrupt-${crypto
129
+ .randomBytes(8)
130
+ .toString("hex")}`;
131
+ fs.renameSync(this.configPath, backupPath);
132
+ logger.warn(
133
+ `Existing config at ${this.configPath} was invalid and could not be read. ` +
134
+ `Backed it up to ${backupPath} and reset to defaults.`,
135
+ );
136
+ this.config = this.defaultConfig();
137
+ this.writeConfig();
138
+ return backupPath;
139
+ }
140
+ }
141
+
88
142
  private defaultConfig(): ConfigFile {
89
143
  const configPerEnvironment: ConfigFile["configPerEnvironment"] = {};
90
144
  for (const env of ALL_ENVIRONMENTS) {
@@ -286,12 +340,18 @@ export class ClientConfiguration {
286
340
  public printStatus() {
287
341
  this.loadConfig();
288
342
  const tokenStatus = this.isTokenValid();
343
+ const cell = this.getEnvironment();
344
+
345
+ // The cell prints on both branches, and before anything else: an org and an
346
+ // environment name alone do not identify a cell (two cells can both hold a
347
+ // `demo`), and a stale token is most often a token for the wrong one.
348
+ console.log(kleur.green(ASCII_TITLE));
349
+ logger.info(`Cell: ${kleur.green(cell)} (${controlPlanesByEnv[cell]})`);
289
350
 
290
351
  if (tokenStatus.valid) {
291
352
  const organization = this.getOrganization();
292
353
  const project = this.getProject();
293
354
 
294
- console.log(kleur.green(ASCII_TITLE));
295
355
  logger.info(`Organization: ${kleur.green(organization || "Not set")}`);
296
356
  logger.info(`Environment: ${kleur.green(project || "Not set")}`);
297
357
  } else {
@@ -7,6 +7,7 @@ import { createGroup } from "./group/creategroup";
7
7
  import { createGroupAccessToken } from "./groupaccesstoken/createToken";
8
8
  import { createEnvironment } from "./environment/createenvironment";
9
9
  import { warnProjectDeprecated } from "./legacy";
10
+ import { createPermission } from "./permission/createpermission";
10
11
 
11
12
  export const registerAddCommands = (cli: Command, environment: Environment) => {
12
13
  const add = cli
@@ -125,6 +126,48 @@ export const registerAddCommands = (cli: Command, environment: Environment) => {
125
126
  );
126
127
  });
127
128
 
129
+ add.command("permission")
130
+ .description(
131
+ "Grant a user or group a permission on the organization, an environment, or a package.",
132
+ )
133
+ .argument(
134
+ "<userGroupId>",
135
+ "Who to grant it to [user:{email}|group:{groupName}|user:*]",
136
+ )
137
+ .argument(
138
+ "<role>",
139
+ "The role to grant (organization: admin|modeler|member; environment: admin|modeler|viewer; package: viewer)",
140
+ )
141
+ .option(
142
+ "--environment <environmentName>",
143
+ "Grant on this environment instead of the organization.",
144
+ )
145
+ .option(
146
+ "--package <packageName>",
147
+ "Grant on this package. Uses --environment, or the current environment.",
148
+ )
149
+ .option(
150
+ "--notifyPeople",
151
+ "Email the person about the new access. Applies to an environment or a package; an organization grant always notifies a user subject.",
152
+ false,
153
+ )
154
+ .option("--debug")
155
+ .option("-y, --yes", "Skip confirmation.")
156
+ .action((userGroupId, role, options) => {
157
+ createPermission(
158
+ controlPlanesByEnv[environment],
159
+ userGroupId,
160
+ role,
161
+ options.debug || false,
162
+ {
163
+ environment: options.environment,
164
+ package: options.package,
165
+ notifyPeople: options.notifyPeople === true,
166
+ yes: options.yes === true,
167
+ },
168
+ );
169
+ });
170
+
128
171
  add.command("group-access-token")
129
172
  .description("Create an access token for a group.")
130
173
  .argument("<groupName>", "The name of the group")
@@ -158,6 +158,173 @@ describe("update-environment", () => {
158
158
  });
159
159
  });
160
160
 
161
+ describe("git-backed modeling", () => {
162
+ it("sends the enrolment flag on its own", async () => {
163
+ updateEnvironmentStub.resolves({ status: 200 });
164
+
165
+ await updateEnvironment(
166
+ "http://test-server",
167
+ "test-environment",
168
+ { gitModeling: "true" },
169
+ false,
170
+ true,
171
+ );
172
+
173
+ expect(updateEnvironmentStub.calledOnce).to.be.true;
174
+ expect(updateEnvironmentStub.firstCall.args[2]).to.deep.equal({
175
+ gitModelingEnabled: true,
176
+ });
177
+ });
178
+
179
+ it("rejects a --git-modeling value that is neither true nor false", async () => {
180
+ await updateEnvironment(
181
+ "http://test-server",
182
+ "test-environment",
183
+ { gitModeling: "yes" },
184
+ false,
185
+ true,
186
+ );
187
+
188
+ expect(updateEnvironmentStub.called).to.be.false;
189
+ expect(
190
+ loggerStub.error.calledWith(
191
+ sinon.match("--git-modeling must be true or false"),
192
+ ),
193
+ ).to.be.true;
194
+ });
195
+
196
+ it("sends all three coordinates together", async () => {
197
+ updateEnvironmentStub.resolves({ status: 200 });
198
+
199
+ await updateEnvironment(
200
+ "http://test-server",
201
+ "test-environment",
202
+ {
203
+ gitRepositoryOwner: "credible-data-repos",
204
+ gitRepositoryName: "models",
205
+ gitBaseBranch: "main",
206
+ },
207
+ false,
208
+ true,
209
+ );
210
+
211
+ expect(updateEnvironmentStub.firstCall.args[2]).to.deep.equal({
212
+ gitRepositoryOwner: "credible-data-repos",
213
+ gitRepositoryName: "models",
214
+ gitBaseBranch: "main",
215
+ });
216
+ });
217
+
218
+ it("refuses a partial coordinate locally rather than sending a 400", async () => {
219
+ // The API rejects this as GIT_REPOSITORY_INCOMPLETE. Refusing here means a
220
+ // script gets the rule from the tool it is driving.
221
+ await updateEnvironment(
222
+ "http://test-server",
223
+ "test-environment",
224
+ { gitRepositoryOwner: "credible-data-repos" },
225
+ false,
226
+ true,
227
+ );
228
+
229
+ expect(updateEnvironmentStub.called).to.be.false;
230
+ expect(loggerStub.error.calledWith(sinon.match("change together")))
231
+ .to.be.true;
232
+ });
233
+
234
+ it("refuses a mix of set and cleared coordinates", async () => {
235
+ await updateEnvironment(
236
+ "http://test-server",
237
+ "test-environment",
238
+ {
239
+ gitRepositoryOwner: "credible-data-repos",
240
+ gitRepositoryName: "",
241
+ gitBaseBranch: "main",
242
+ },
243
+ false,
244
+ true,
245
+ );
246
+
247
+ expect(updateEnvironmentStub.called).to.be.false;
248
+ expect(
249
+ loggerStub.error.calledWith(sinon.match("unset the repository")),
250
+ ).to.be.true;
251
+ });
252
+
253
+ it("sends nulls when all three are emptied, which is how a record is unset", async () => {
254
+ updateEnvironmentStub.resolves({ status: 200 });
255
+
256
+ await updateEnvironment(
257
+ "http://test-server",
258
+ "test-environment",
259
+ {
260
+ gitRepositoryOwner: "",
261
+ gitRepositoryName: "",
262
+ gitBaseBranch: " ",
263
+ },
264
+ false,
265
+ true,
266
+ );
267
+
268
+ expect(updateEnvironmentStub.firstCall.args[2]).to.deep.equal({
269
+ gitRepositoryOwner: null,
270
+ gitRepositoryName: null,
271
+ gitBaseBranch: null,
272
+ });
273
+ });
274
+
275
+ it("carries the enrolment and the coordinates in one request", async () => {
276
+ updateEnvironmentStub.resolves({ status: 200 });
277
+
278
+ await updateEnvironment(
279
+ "http://test-server",
280
+ "test-environment",
281
+ {
282
+ gitModeling: "true",
283
+ gitRepositoryOwner: "credible-data-repos",
284
+ gitRepositoryName: "models",
285
+ gitBaseBranch: "main",
286
+ },
287
+ false,
288
+ true,
289
+ );
290
+
291
+ expect(updateEnvironmentStub.firstCall.args[2]).to.deep.equal({
292
+ gitModelingEnabled: true,
293
+ gitRepositoryOwner: "credible-data-repos",
294
+ gitRepositoryName: "models",
295
+ gitBaseBranch: "main",
296
+ });
297
+ });
298
+
299
+ it("reports a refusal from the server rather than claiming success", async () => {
300
+ updateEnvironmentStub.rejects(
301
+ Object.assign(new Error("Request failed with status code 400"), {
302
+ response: {
303
+ status: 400,
304
+ data: { code: "GIT_REPOSITORY_NOT_REGISTERED" },
305
+ },
306
+ }),
307
+ );
308
+
309
+ await updateEnvironment(
310
+ "http://test-server",
311
+ "test-environment",
312
+ { gitModeling: "true" },
313
+ false,
314
+ true,
315
+ );
316
+
317
+ expect(
318
+ loggerStub.error.calledWith(
319
+ sinon.match("Failed to update environment test-environment"),
320
+ ),
321
+ ).to.be.true;
322
+ expect(
323
+ loggerStub.info.calledWith(sinon.match("Successfully updated")),
324
+ ).to.be.false;
325
+ });
326
+ });
327
+
161
328
  describe("replication count validation", () => {
162
329
  it("should accept valid replication counts", async () => {
163
330
  const mockResponse = { status: 200 };
@@ -8,8 +8,60 @@ const environmentsApi = new EnvironmentsApi(new Configuration());
8
8
 
9
9
  type UpdateEnvironmentProperties = {
10
10
  replicationCount?: number;
11
+ /** Raw `--git-modeling` value; parsed here so a typo is refused rather than sent. */
12
+ gitModeling?: string;
13
+ gitRepositoryOwner?: string;
14
+ gitRepositoryName?: string;
15
+ gitBaseBranch?: string;
11
16
  };
12
17
 
18
+ /**
19
+ * The three repository coordinates change as a unit, and the API rejects any other
20
+ * shape (`GIT_REPOSITORY_INCOMPLETE`, `GIT_REPOSITORY_INCOHERENT`). Refused here too, so
21
+ * a scripted caller gets the rule from the tool it is driving rather than from a 400.
22
+ *
23
+ * Returns the body fragment, or a message to print instead of sending anything.
24
+ */
25
+ function gitRepositoryRecord(
26
+ properties: UpdateEnvironmentProperties,
27
+ ): { changes: Partial<Environment> } | { error: string } {
28
+ const mentioned = [
29
+ properties.gitRepositoryOwner,
30
+ properties.gitRepositoryName,
31
+ properties.gitBaseBranch,
32
+ ].filter((value) => value !== undefined) as string[];
33
+
34
+ if (mentioned.length === 0) {
35
+ return { changes: {} };
36
+ }
37
+ if (mentioned.length !== 3) {
38
+ return {
39
+ error:
40
+ "--git-repository-owner, --git-repository-name and --git-base-branch change " +
41
+ "together. Pass all three, or none.",
42
+ };
43
+ }
44
+
45
+ const trimmed = mentioned.map((value) => value.trim());
46
+ const cleared = trimmed.filter((value) => value === "").length;
47
+ if (cleared !== 0 && cleared !== 3) {
48
+ return {
49
+ error:
50
+ "Set all three of --git-repository-owner, --git-repository-name and " +
51
+ '--git-base-branch, or pass "" for all three to unset the repository.',
52
+ };
53
+ }
54
+
55
+ const [owner, name, baseBranch] = trimmed;
56
+ return {
57
+ changes: {
58
+ gitRepositoryOwner: owner || null,
59
+ gitRepositoryName: name || null,
60
+ gitBaseBranch: baseBranch || null,
61
+ },
62
+ };
63
+ }
64
+
13
65
  export async function updateEnvironment(
14
66
  controlPlane: string,
15
67
  environmentName: string,
@@ -38,6 +90,21 @@ export async function updateEnvironment(
38
90
  return;
39
91
  }
40
92
 
93
+ if (
94
+ properties.gitModeling !== undefined &&
95
+ properties.gitModeling !== "true" &&
96
+ properties.gitModeling !== "false"
97
+ ) {
98
+ logger.error("--git-modeling must be true or false");
99
+ return;
100
+ }
101
+
102
+ const repositoryRecord = gitRepositoryRecord(properties);
103
+ if ("error" in repositoryRecord) {
104
+ logger.error(repositoryRecord.error);
105
+ return;
106
+ }
107
+
41
108
  try {
42
109
  if (!skipConfirmation) {
43
110
  const confirmation = await getConfirmation(
@@ -49,12 +116,17 @@ export async function updateEnvironment(
49
116
  }
50
117
  }
51
118
 
52
- const environmentUpdate: Environment = {};
119
+ const environmentUpdate: Environment = { ...repositoryRecord.changes };
53
120
 
54
121
  if (properties.replicationCount !== undefined) {
55
122
  environmentUpdate.replicationCount = properties.replicationCount;
56
123
  }
57
124
 
125
+ if (properties.gitModeling !== undefined) {
126
+ environmentUpdate.gitModelingEnabled =
127
+ properties.gitModeling === "true";
128
+ }
129
+
58
130
  const response = await environmentsApi.updateEnvironment(
59
131
  organizationName,
60
132
  environmentName,
@@ -72,6 +144,18 @@ export async function updateEnvironment(
72
144
  `Replication count set to ${properties.replicationCount}`,
73
145
  );
74
146
  }
147
+ if (properties.gitModeling !== undefined) {
148
+ logger.info(
149
+ `Git-backed modeling ${properties.gitModeling === "true" ? "enabled" : "disabled"}`,
150
+ );
151
+ }
152
+ if (environmentUpdate.gitRepositoryOwner !== undefined) {
153
+ logger.info(
154
+ environmentUpdate.gitRepositoryOwner
155
+ ? `Repository set to ${environmentUpdate.gitRepositoryOwner}/${environmentUpdate.gitRepositoryName} (${environmentUpdate.gitBaseBranch})`
156
+ : "Repository unset",
157
+ );
158
+ }
75
159
  } else {
76
160
  logger.error(
77
161
  `Failed to update environment. Status: ${response.status}`,