@getantidote/cli 0.1.0 → 1.0.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.
package/README.md CHANGED
@@ -64,9 +64,9 @@ jobs:
64
64
  (`ANTIDOTE_OIDC_TOKEN` or `ANTIDOTE_OIDC_TOKEN_FILE`), without `ANTIDOTE_SERVICE_IDENTITY`.
65
65
  The platform finds the identity from the token itself: the one that belongs to
66
66
  your organisation's connection to that CI platform (Settings → Integrations →
67
- Source control & CI/CD), so there is nothing to create or name. No CI
68
- platform's connection provides one yet; until one does, the platform refuses
69
- these tokens. The token's audience must be the platform's URL (the origin of
67
+ Source control & CI/CD), so there is nothing to create or name. Connecting
68
+ GitLab CI/CD creates it, for GitLab jobs' ID tokens; the platform refuses
69
+ other CI platforms' tokens this way. The token's audience must be the platform's URL (the origin of
70
70
  `ANTIDOTE_API_URL`). When the platform refuses a token, the CLI prints its reason as
71
71
  it gave it.
72
72
 
@@ -142,6 +142,8 @@ empty one.
142
142
  antidote whoami # the signed-in user (or service identity), organisation and auth mode (a tool command, below)
143
143
  antidote tools # the tools available to the CLI
144
144
  antidote tools --refresh # re-fetch the tool list
145
+ antidote wait-for-deployment --product-id <id> --commit-sha <sha>
146
+ # poll the environment's URL until it serves the commit (--help: its flags)
145
147
  antidote logout # revoke the session and delete this machine's credentials
146
148
  antidote --version
147
149
  ```
@@ -214,6 +216,10 @@ the tool's input schema. `antidote <tool> --help` lists its flags.
214
216
 
215
217
  ### In CI
216
218
 
219
+ On GitLab, the `preview` and `release` components (`actions-repo/gitlab`) make
220
+ these calls for you and run the CLI's major version, `@getantidote/cli@1`. The
221
+ jobs below show the calls themselves.
222
+
217
223
  A job that needs a tool's result in a later job writes it as a dotenv file. In
218
224
  GitLab, with `ANTIDOTE_API_KEY` set as a masked CI/CD variable and the product's
219
225
  id given to the tool as `ANTIDOTE_INPUT_PRODUCT_ID`:
@@ -226,7 +232,7 @@ antidote-preview:
226
232
  rules:
227
233
  - if: $CI_MERGE_REQUEST_IID
228
234
  script:
229
- - npx -y @getantidote/cli@0.1 record-preview --provider gitlab --host "$CI_SERVER_FQDN" --repository "$CI_PROJECT_PATH" --repository-id "$CI_PROJECT_ID" --number "$CI_MERGE_REQUEST_IID" --head-sha "$CI_COMMIT_SHA" --branch "$CI_COMMIT_REF_NAME" --pipeline-id "$CI_PIPELINE_ID" --dotenv antidote.env
235
+ - npx -y @getantidote/cli@1 record-preview --provider gitlab --host "$CI_SERVER_FQDN" --repository "$CI_PROJECT_PATH" --repository-id "$CI_PROJECT_ID" --number "$CI_MERGE_REQUEST_IID" --head-sha "$CI_COMMIT_SHA" --branch "$CI_COMMIT_REF_NAME" --pipeline-id "$CI_PIPELINE_ID" --dotenv antidote.env
230
236
  artifacts:
231
237
  when: always
232
238
  reports:
@@ -254,7 +260,7 @@ antidote-release:
254
260
  variables:
255
261
  ANTIDOTE_INPUT_PRODUCT_ID: prod_123
256
262
  script:
257
- - npx -y @getantidote/cli@0.1 record-release --environment staging --build-sha "$CI_COMMIT_SHA" --provider gitlab --host "$CI_SERVER_FQDN" --repository "$CI_PROJECT_PATH" --repository-id "$CI_PROJECT_ID" --dotenv antidote.env
263
+ - npx -y @getantidote/cli@1 record-release --environment staging --build-sha "$CI_COMMIT_SHA" --provider gitlab --host "$CI_SERVER_FQDN" --repository "$CI_PROJECT_PATH" --repository-id "$CI_PROJECT_ID" --dotenv antidote.env
258
264
  artifacts:
259
265
  when: always
260
266
  reports:
package/dist/antidote.mjs CHANGED
@@ -16941,7 +16941,7 @@ async function openMcpSession(apiUrl, credentials, deps) {
16941
16941
 
16942
16942
  // src/tool-commands.ts
16943
16943
  import { parseArgs } from "node:util";
16944
- var BUILTIN_COMMANDS = ["login", "logout", "tools"];
16944
+ var BUILTIN_COMMANDS = ["login", "logout", "tools", "wait-for-deployment"];
16945
16945
  var RESERVED_FLAGS = /* @__PURE__ */ new Set(["json", "help", "version", "device", "refresh", "dotenv"]);
16946
16946
  var UsageError = class extends Error {
16947
16947
  /** The --dotenv the strict parse accepted, when the mistake came after it. */
@@ -17135,6 +17135,74 @@ async function loadTools(source, opts = {}) {
17135
17135
  return list;
17136
17136
  }
17137
17137
 
17138
+ // src/wait-for-deployment.ts
17139
+ var DEFAULT_WAIT_COMMIT_HEADER = "x-commit-sha";
17140
+ var DEFAULT_WAIT_COMMIT_PATH = "/";
17141
+ var DEFAULT_WAIT_TIMEOUT_SECONDS = 300;
17142
+ var DEFAULT_WAIT_INTERVAL_SECONDS = 10;
17143
+ var PROBE_TIMEOUT_MS = 1e4;
17144
+ function buildPollUrl(baseUrl, path) {
17145
+ const base = baseUrl.replace(/\/+$/, "");
17146
+ const trimmed = (path || "").trim() || "/";
17147
+ const suffix = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
17148
+ return `${base}${suffix}`;
17149
+ }
17150
+ function commitHeaderMatches(headerValue, expectedSha) {
17151
+ const normalized = (headerValue || "").trim().toLowerCase();
17152
+ const expected = (expectedSha || "").trim().toLowerCase();
17153
+ if (!normalized || !expected) return false;
17154
+ if (normalized === expected) return true;
17155
+ return normalized === expected.slice(0, 7);
17156
+ }
17157
+ function fetchProbe(http, headerName) {
17158
+ return async (url2) => {
17159
+ const response = await http(url2, { headers: { accept: "*/*" }, signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
17160
+ await response.body?.cancel().catch(() => void 0);
17161
+ return response.headers.get(headerName);
17162
+ };
17163
+ }
17164
+ function environmentUrl(environments, name) {
17165
+ const target = environments.find((e) => name ? e.name?.text === name : e.is_default);
17166
+ if (!target) throw new Error(name ? `The product has no environment named ${name}.` : "The product has no default environment.");
17167
+ if (!target.url) throw new Error(`Environment ${name || "(the default)"} has no URL in Duku.`);
17168
+ return target.url;
17169
+ }
17170
+ function defaultSleep(ms) {
17171
+ return new Promise((resolve) => setTimeout(resolve, ms));
17172
+ }
17173
+ async function waitForDeployment(opts) {
17174
+ const { pollUrl, expectedSha, headerName, timeoutSeconds, intervalSeconds, probe, sleep = defaultSleep, now = Date.now, log } = opts;
17175
+ const expected = expectedSha.trim();
17176
+ const short = expected.slice(0, 7);
17177
+ log?.(`Waiting up to ${timeoutSeconds}s for ${pollUrl} to serve commit ${short} (header \`${headerName}\`)...`);
17178
+ const deadline = now() + timeoutSeconds * 1e3;
17179
+ let attempt = 0;
17180
+ for (; ; ) {
17181
+ attempt += 1;
17182
+ let value = null;
17183
+ try {
17184
+ value = await probe(pollUrl);
17185
+ } catch (err) {
17186
+ log?.(`Attempt ${attempt}: request to ${pollUrl} failed (${err instanceof Error ? err.message : String(err)}) \u2014 retrying...`);
17187
+ }
17188
+ if (value !== null && commitHeaderMatches(value, expected)) {
17189
+ log?.(`Attempt ${attempt}: ${pollUrl} serves commit ${short} \u2014 proceeding.`);
17190
+ return;
17191
+ }
17192
+ if (value !== null) {
17193
+ log?.(`Attempt ${attempt}: serves '${value.trim() || "(empty)"}' \u2014 waiting for '${short}'...`);
17194
+ } else if (attempt === 1) {
17195
+ log?.(`Attempt 1: no \`${headerName}\` header yet \u2014 waiting for '${short}'...`);
17196
+ }
17197
+ if (now() >= deadline) break;
17198
+ const remaining = deadline - now();
17199
+ await sleep(Math.min(intervalSeconds * 1e3, Math.max(remaining, 0)));
17200
+ }
17201
+ throw new Error(
17202
+ `Timed out after ${timeoutSeconds}s waiting for ${pollUrl} to serve commit ${expected}. Expose the commit as response header \`${headerName}\` (e.g. \`res.setHeader('${headerName}', process.env.COMMIT_SHA)\`) so the job can tell the new deploy apart from the previous build.`
17203
+ );
17204
+ }
17205
+
17138
17206
  // src/cli.ts
17139
17207
  var USAGE = `Usage: antidote <command> [--json]
17140
17208
 
@@ -17143,6 +17211,8 @@ Commands:
17143
17211
  the default over SSH or without a display)
17144
17212
  logout Sign out and forget this machine's credentials
17145
17213
  tools List the tools available to the CLI (--refresh: re-fetch the list)
17214
+ wait-for-deployment
17215
+ Wait until an environment serves a commit (--help: its flags)
17146
17216
 
17147
17217
  The platform's tools are commands too, such as \`antidote whoami\`.
17148
17218
  \`antidote <tool> --help\` shows a tool's flags.
@@ -17221,6 +17291,9 @@ async function run(argv, deps) {
17221
17291
  return ctx;
17222
17292
  };
17223
17293
  try {
17294
+ if (command === "wait-for-deployment" && !leading.some((arg) => VERSION_FLAGS.has(arg))) {
17295
+ return await runWaitForDeployment(argv.slice(commandAt + 1), deps, context);
17296
+ }
17224
17297
  return isBuiltin ? await runBuiltin(argv, deps, context) : await runTool(command, [...argv.slice(0, commandAt), ...argv.slice(commandAt + 1)], deps, context);
17225
17298
  } catch (e) {
17226
17299
  if (e instanceof UsageError) {
@@ -17329,6 +17402,73 @@ async function runBuiltin(argv, deps, context) {
17329
17402
  return 2;
17330
17403
  }
17331
17404
  }
17405
+ var WAIT_USAGE = `Usage: antidote wait-for-deployment --product-id <id> --commit-sha <sha> [flags]
17406
+
17407
+ Poll the environment's URL, as Duku has it, until the response header names
17408
+ the commit (its full or 7-character SHA).
17409
+
17410
+ --product-id <id> The Duku product
17411
+ --environment <name> The environment (default: the product's default)
17412
+ --commit-sha <sha> The commit to wait for
17413
+ --commit-header <name> Response header the app exposes its deployed
17414
+ commit on (default ${DEFAULT_WAIT_COMMIT_HEADER})
17415
+ --commit-path <path> Path polled on the environment's URL (default ${DEFAULT_WAIT_COMMIT_PATH})
17416
+ --timeout-seconds <n> How long to wait before failing (default ${DEFAULT_WAIT_TIMEOUT_SECONDS})
17417
+ --interval-seconds <n> Delay between polls (default ${DEFAULT_WAIT_INTERVAL_SECONDS})`;
17418
+ async function runWaitForDeployment(args, deps, context) {
17419
+ let values;
17420
+ try {
17421
+ ({ values } = parseArgs2({
17422
+ args,
17423
+ options: {
17424
+ "product-id": { type: "string" },
17425
+ environment: { type: "string", default: "" },
17426
+ "commit-sha": { type: "string" },
17427
+ "commit-header": { type: "string", default: DEFAULT_WAIT_COMMIT_HEADER },
17428
+ "commit-path": { type: "string", default: DEFAULT_WAIT_COMMIT_PATH },
17429
+ "timeout-seconds": { type: "string", default: String(DEFAULT_WAIT_TIMEOUT_SECONDS) },
17430
+ "interval-seconds": { type: "string", default: String(DEFAULT_WAIT_INTERVAL_SECONDS) },
17431
+ help: { type: "boolean", short: "h", default: false }
17432
+ }
17433
+ }));
17434
+ } catch (e) {
17435
+ deps.err(e.message);
17436
+ deps.err(WAIT_USAGE);
17437
+ return 2;
17438
+ }
17439
+ if (values.help) {
17440
+ deps.out(WAIT_USAGE);
17441
+ return 0;
17442
+ }
17443
+ const productId = values["product-id"]?.trim();
17444
+ const commitSha = values["commit-sha"]?.trim();
17445
+ const headerName = values["commit-header"].trim();
17446
+ const timeoutSeconds = Number(values["timeout-seconds"]);
17447
+ const intervalSeconds = Number(values["interval-seconds"]);
17448
+ if (!productId || !commitSha || !headerName || !(timeoutSeconds > 0) || !(intervalSeconds > 0)) {
17449
+ deps.err("--product-id, --commit-sha and a --commit-header are required, and the seconds must be numbers above 0.");
17450
+ deps.err(WAIT_USAGE);
17451
+ return 2;
17452
+ }
17453
+ const result = await (await context().session()).callTool("get_product", { product_id: productId });
17454
+ if (result.isError) {
17455
+ deps.err(`Error: ${printable(contentText(result.content)) || "get_product failed"}`);
17456
+ return 1;
17457
+ }
17458
+ const environments = result.structuredContent?.environments ?? [];
17459
+ const pollUrl = buildPollUrl(environmentUrl(environments, values.environment.trim()), values["commit-path"]);
17460
+ await waitForDeployment({
17461
+ pollUrl,
17462
+ expectedSha: commitSha,
17463
+ headerName,
17464
+ timeoutSeconds,
17465
+ intervalSeconds,
17466
+ probe: fetchProbe(deps.http, headerName),
17467
+ sleep: deps.sleep,
17468
+ log: (line) => deps.err(printable(line))
17469
+ });
17470
+ return 0;
17471
+ }
17332
17472
  async function runTool(name, args, deps, context) {
17333
17473
  const files = {
17334
17474
  writeFile: deps.writeFile ?? ((path, data) => writeFile2(path, data, { flag: "wx" })),
@@ -17428,7 +17568,7 @@ var code = await run(process.argv.slice(2), {
17428
17568
  err: (line) => process.stderr.write(`${line}
17429
17569
  `),
17430
17570
  openBrowser,
17431
- version: true ? "0.1.0" : "0.0.0-dev"
17571
+ version: true ? "1.0.0" : "0.0.0-dev"
17432
17572
  });
17433
17573
  process.exitCode = code;
17434
17574
  /*! Bundled license information:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getantidote/cli",
3
- "version": "0.1.0",
3
+ "version": "1.0.0",
4
4
  "description": "Antidote command-line interface",
5
5
  "license": "MIT",
6
6
  "type": "module",