trustci 0.2.2 → 0.3.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.
Files changed (3) hide show
  1. package/README.md +26 -1
  2. package/dist/cli.js +221 -32
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -32,7 +32,7 @@ npx trustci list
32
32
  | ---------- | ---------------------------------------------------------------------------------------------- | --------------- |
33
33
  | `github` | `--repo <owner/repo>` `--file <workflow>` | `--env <name>` |
34
34
  | `gitlab` | `--project <group/project>` (or `--repo`) `--file <workflow>` | `--env <name>` |
35
- | `circleci` | `--org-id` `--project-id` `--pipeline-definition-id` `--vcs-origin <host/owner/repo>` | `--context-id` (repeatable) |
35
+ | `circleci` | Either all of `--org-id` `--project-id` `--pipeline-definition-id`, or a CircleCI token | `--vcs-origin <host/owner/repo>`, `--circle-token`, `--file`, `--context-id` (repeatable) |
36
36
 
37
37
  At least one of `--allow-publish` / `--allow-stage-publish` is required.
38
38
 
@@ -48,6 +48,31 @@ npx trustci --provider circleci \
48
48
  --allow-publish
49
49
  ```
50
50
 
51
+ #### CircleCI `--vcs-origin`
52
+
53
+ When `--vcs-origin` is omitted, trustci takes it from the root `package.json`
54
+ `repository` field (URL, `git+https://…`, `git@host:owner/repo`, or the
55
+ `github:` / `gitlab:` / `bitbucket:` / `owner/repo` shorthands), then from
56
+ `git remote get-url origin`. It prints which one it used, and warns when the two
57
+ disagree (`package.json` wins, since it is committed rather than clone-specific).
58
+
59
+ #### CircleCI token lookup
60
+
61
+ When any of the three UUIDs is missing, trustci resolves it through the CircleCI
62
+ API v2. The token is taken from the first of these that is set, and trustci
63
+ prints which one it used (never the token itself):
64
+
65
+ 1. `--circle-token`
66
+ 2. `CIRCLE_TOKEN` (circleci CLI v1, chunk)
67
+ 3. `CIRCLECI_TOKEN` (chunk)
68
+ 4. `CIRCLE_CLI_TOKEN` (circleci CLI v1, legacy alias)
69
+ 5. `CIRCLECI_CLI_TOKEN` (circleci CLI v0.1)
70
+ 6. `token` in `~/.circleci/cli.yml`, written by `circleci setup` in circleci CLI v0.1.
71
+ It is ignored when its `host` is not `https://circleci.com`.
72
+
73
+ The OS keychain, where circleci CLI v1 and chunk store tokens by default, is not read.
74
+ When all three UUIDs are given, no token is looked up.
75
+
51
76
  ### Common options
52
77
 
53
78
  | Flag | Description |
package/dist/cli.js CHANGED
@@ -37,13 +37,24 @@ function projectSlugFromVcsOrigin(origin) {
37
37
  }
38
38
  return `${vcsType}/${parts[0]}/${parts[1]}`;
39
39
  }
40
+
41
+ class CircleApiError extends Error {
42
+ status;
43
+ path;
44
+ body;
45
+ constructor(status, path, body) {
46
+ super(`CircleCI API error (${status}) for ${path}: ${body}`);
47
+ this.status = status;
48
+ this.path = path;
49
+ this.body = body;
50
+ }
51
+ }
40
52
  async function circleGet(token, path) {
41
53
  const response = await fetch(`${BASE_URL}${path}`, {
42
54
  headers: { "Circle-Token": token, Accept: "application/json" }
43
55
  });
44
56
  if (!response.ok) {
45
- const body = await response.text();
46
- throw new Error(`CircleCI API error (${response.status}) for ${path}: ${body}`);
57
+ throw new CircleApiError(response.status, path, await response.text());
47
58
  }
48
59
  return response.json();
49
60
  }
@@ -168,26 +179,17 @@ function parseCliArgs(argv) {
168
179
  break;
169
180
  }
170
181
  case "circleci": {
182
+ const rawVcsOrigin = values["vcs-origin"];
171
183
  let vcsOrigin;
172
184
  try {
173
- vcsOrigin = normalizeVcsOrigin(require_(values["vcs-origin"], "vcs-origin", "circleci"));
185
+ vcsOrigin = rawVcsOrigin === undefined ? undefined : normalizeVcsOrigin(rawVcsOrigin);
174
186
  } catch (e) {
175
- if (e instanceof ArgError)
176
- throw e;
177
187
  throw new ArgError(`--${e.message}`);
178
188
  }
179
189
  const circleToken = values["circle-token"];
180
190
  const orgId = values["org-id"];
181
191
  const projectId = values["project-id"];
182
192
  const pipelineDefinitionId = values["pipeline-definition-id"];
183
- if (!circleToken) {
184
- if (!orgId)
185
- throw new ArgError("--org-id is required for --provider circleci (or provide --circle-token for auto-resolution).");
186
- if (!projectId)
187
- throw new ArgError("--project-id is required for --provider circleci (or provide --circle-token for auto-resolution).");
188
- if (!pipelineDefinitionId)
189
- throw new ArgError("--pipeline-definition-id is required for --provider circleci (or provide --circle-token for auto-resolution).");
190
- }
191
193
  providerOptions = {
192
194
  provider: "circleci",
193
195
  circleToken,
@@ -226,27 +228,211 @@ function runInherit(cmd, args) {
226
228
  return res.status ?? (res.error || res.signal ? 1 : 0);
227
229
  }
228
230
 
231
+ // src/resolve.ts
232
+ import { readFileSync } from "node:fs";
233
+ import { homedir } from "node:os";
234
+ import { join as join2 } from "node:path";
235
+
236
+ // src/token.ts
237
+ import { join } from "node:path";
238
+ var DEFAULT_HOST = "https://circleci.com";
239
+ var LEGACY_CLI_YML = "~/.circleci/cli.yml";
240
+ var TOKEN_ENV_VARS = ["CIRCLE_TOKEN", "CIRCLECI_TOKEN", "CIRCLE_CLI_TOKEN", "CIRCLECI_CLI_TOKEN"];
241
+ var TOKEN_SOURCES = ["--circle-token", ...TOKEN_ENV_VARS, LEGACY_CLI_YML];
242
+ function parseLegacyCliYml(content) {
243
+ const out = {};
244
+ for (const raw of content.split(/\r?\n/)) {
245
+ const m = /^(token|host):(.*)$/.exec(raw);
246
+ if (!m)
247
+ continue;
248
+ const value = parseScalar(m[2].trim());
249
+ if (value)
250
+ out[m[1]] = value;
251
+ }
252
+ return out;
253
+ }
254
+ function parseScalar(v) {
255
+ const quoted = /^(["'])(.*)\1(\s+#.*)?$/.exec(v);
256
+ if (quoted)
257
+ return quoted[2];
258
+ return v.replace(/\s+#.*$/, "").trim();
259
+ }
260
+ function resolveCircleToken(input) {
261
+ if (input.flag)
262
+ return { token: input.flag, source: "--circle-token", notes: [] };
263
+ for (const name of TOKEN_ENV_VARS) {
264
+ const value = input.env[name];
265
+ if (value)
266
+ return { token: value, source: name, notes: [] };
267
+ }
268
+ const content = input.readFile(join(input.homedir, ".circleci", "cli.yml"));
269
+ if (content === null)
270
+ return { token: undefined, notes: [] };
271
+ const { token, host } = parseLegacyCliYml(content);
272
+ if (!token)
273
+ return { token: undefined, notes: [] };
274
+ if (token === "|" || token === ">" || /^[|>][-+0-9]*$/.test(token)) {
275
+ return { token: undefined, notes: [`skipped ${LEGACY_CLI_YML}: its token is not a single-line value trustci can read`] };
276
+ }
277
+ if (host && host.replace(/\/+$/, "") !== DEFAULT_HOST) {
278
+ return { token: undefined, notes: [`skipped ${LEGACY_CLI_YML}: its token is for ${host}, not ${DEFAULT_HOST}`] };
279
+ }
280
+ return { token, source: LEGACY_CLI_YML, notes: [] };
281
+ }
282
+
283
+ // src/vcs.ts
284
+ var SHORTHAND_HOSTS = {
285
+ github: "github.com",
286
+ gitlab: "gitlab.com",
287
+ bitbucket: "bitbucket.org"
288
+ };
289
+ var PACKAGE_JSON = "package.json repository";
290
+ var GIT_REMOTE = "git remote origin";
291
+ function parseRepositoryUrl(input) {
292
+ const s = input.trim();
293
+ let host;
294
+ let path;
295
+ const shorthand = /^(github|gitlab|bitbucket):(.+)$/.exec(s);
296
+ const scp = /^[^@/\s]+@([^:/\s]+):(.+)$/.exec(s);
297
+ if (shorthand) {
298
+ host = SHORTHAND_HOSTS[shorthand[1]];
299
+ path = shorthand[2];
300
+ } else if (/^[^/:\s]+\/[^/:\s]+$/.test(s)) {
301
+ host = "github.com";
302
+ path = s;
303
+ } else if (scp) {
304
+ host = scp[1];
305
+ path = scp[2];
306
+ } else {
307
+ let url;
308
+ try {
309
+ url = new URL(s.replace(/^git\+/, ""));
310
+ } catch {
311
+ return null;
312
+ }
313
+ if (!["https:", "http:", "ssh:", "git:"].includes(url.protocol) || !url.hostname)
314
+ return null;
315
+ host = url.hostname;
316
+ path = url.pathname;
317
+ }
318
+ const parts = path.replace(/#.*$/, "").replace(/^\/+|\/+$/g, "").replace(/\.git$/, "").split("/");
319
+ if (parts.length < 2 || parts.some((p) => !p))
320
+ return null;
321
+ return `${host}/${parts.join("/")}`;
322
+ }
323
+ function repositoryFromPackageJson(repository) {
324
+ if (typeof repository === "string")
325
+ return repository;
326
+ if (repository && typeof repository === "object" && "url" in repository) {
327
+ const url = repository.url;
328
+ if (typeof url === "string")
329
+ return url;
330
+ }
331
+ return;
332
+ }
333
+ function resolveVcsOrigin(input) {
334
+ if (input.flag)
335
+ return { value: input.flag, source: "--vcs-origin", warnings: [] };
336
+ const warnings = [];
337
+ const fromGit = input.gitRemote ? parseRepositoryUrl(input.gitRemote) : null;
338
+ if (input.pkgRepository !== undefined) {
339
+ const fromPkg = parseRepositoryUrl(input.pkgRepository);
340
+ if (fromPkg) {
341
+ if (fromGit && fromGit !== fromPkg) {
342
+ warnings.push(`${PACKAGE_JSON} (${fromPkg}) differs from ${GIT_REMOTE} (${fromGit}); using ${fromPkg}`);
343
+ }
344
+ return { value: fromPkg, source: PACKAGE_JSON, warnings };
345
+ }
346
+ warnings.push(`could not read a repository from ${PACKAGE_JSON} ("${input.pkgRepository}")`);
347
+ }
348
+ if (fromGit)
349
+ return { value: fromGit, source: GIT_REMOTE, warnings };
350
+ return { value: undefined, warnings };
351
+ }
352
+
229
353
  // src/resolve.ts
230
354
  var defaultResolveDeps = {
231
355
  resolveOrgAndProjectId,
232
356
  resolvePipelineDefinitionId,
233
- log: (message) => console.error(message)
357
+ log: (message) => console.error(message),
358
+ env: process.env,
359
+ homedir: homedir(),
360
+ readFile: (path) => {
361
+ try {
362
+ return readFileSync(path, "utf8");
363
+ } catch {
364
+ return null;
365
+ }
366
+ },
367
+ cwd: process.cwd(),
368
+ gitRemoteUrl: () => {
369
+ const r = run("git", ["remote", "get-url", "origin"]);
370
+ return r.status === 0 && r.stdout.trim() ? r.stdout.trim() : null;
371
+ }
234
372
  };
373
+ function resolveMissingVcsOrigin(opts, deps) {
374
+ if (opts.vcsOrigin)
375
+ return opts.vcsOrigin;
376
+ let pkgRepository;
377
+ const pkgText = deps.readFile(join2(deps.cwd, "package.json"));
378
+ if (pkgText !== null) {
379
+ try {
380
+ pkgRepository = repositoryFromPackageJson(JSON.parse(pkgText).repository);
381
+ } catch {
382
+ deps.log("warning: could not parse package.json while looking for a repository");
383
+ }
384
+ }
385
+ const found = resolveVcsOrigin({ pkgRepository, gitRemote: deps.gitRemoteUrl() });
386
+ for (const w of found.warnings)
387
+ deps.log(`warning: ${w}`);
388
+ if (found.value === undefined) {
389
+ throw new Error("--vcs-origin is required for --provider circleci and could not be inferred " + "(looked in: package.json repository, git remote origin). Pass e.g. --vcs-origin github.com/org/repo.");
390
+ }
391
+ deps.log(`vcs-origin: ${found.value} (from ${found.source})`);
392
+ opts.vcsOrigin = found.value;
393
+ return found.value;
394
+ }
235
395
  async function resolveCircleciOptions(parsed, deps = defaultResolveDeps) {
236
396
  const opts = parsed.options;
237
397
  if (opts.provider !== "circleci")
238
398
  return;
239
- if (!opts.circleToken)
399
+ const vcsOrigin = resolveMissingVcsOrigin(opts, deps);
400
+ const missing = [
401
+ !opts.orgId && "--org-id",
402
+ !opts.projectId && "--project-id",
403
+ !opts.pipelineDefinitionId && "--pipeline-definition-id"
404
+ ].filter((m) => Boolean(m));
405
+ if (missing.length === 0)
240
406
  return;
241
- if (!opts.orgId || !opts.projectId) {
242
- deps.log("Resolving org-id and project-id via CircleCI API...");
243
- const resolved = await deps.resolveOrgAndProjectId(opts.circleToken, opts.vcsOrigin);
244
- opts.orgId ??= resolved.orgId;
245
- opts.projectId ??= resolved.projectId;
407
+ const found = resolveCircleToken({
408
+ flag: opts.circleToken,
409
+ env: deps.env,
410
+ homedir: deps.homedir,
411
+ readFile: deps.readFile
412
+ });
413
+ for (const note of found.notes)
414
+ deps.log(`note: ${note}`);
415
+ if (found.token === undefined) {
416
+ throw new Error(`missing ${missing.join(", ")} for --provider circleci. Pass them explicitly, ` + `or provide a CircleCI API token so they can be resolved (looked in: ${TOKEN_SOURCES.join(", ")}).`);
246
417
  }
247
- if (!opts.pipelineDefinitionId) {
248
- deps.log("Resolving pipeline-definition-id via CircleCI API...");
249
- opts.pipelineDefinitionId = await deps.resolvePipelineDefinitionId(opts.circleToken, opts.projectId, opts.file);
418
+ const { token, source } = found;
419
+ deps.log(`Using CircleCI token from ${source}`);
420
+ try {
421
+ if (!opts.orgId || !opts.projectId) {
422
+ deps.log("Resolving org-id and project-id via CircleCI API...");
423
+ const resolved = await deps.resolveOrgAndProjectId(token, vcsOrigin);
424
+ opts.orgId ??= resolved.orgId;
425
+ opts.projectId ??= resolved.projectId;
426
+ }
427
+ if (!opts.pipelineDefinitionId) {
428
+ deps.log("Resolving pipeline-definition-id via CircleCI API...");
429
+ opts.pipelineDefinitionId = await deps.resolvePipelineDefinitionId(token, opts.projectId, opts.file);
430
+ }
431
+ } catch (err) {
432
+ if (err instanceof CircleApiError && (err.status === 401 || err.status === 403)) {
433
+ throw new Error(`CircleCI rejected the token from ${source} (${err.status}). ` + `Pass a valid token with --circle-token or the CIRCLE_TOKEN environment variable.`);
434
+ }
435
+ throw err;
250
436
  }
251
437
  }
252
438
 
@@ -337,7 +523,7 @@ function displayCommand(args) {
337
523
  }
338
524
 
339
525
  // src/workspace.ts
340
- import { existsSync, readFileSync } from "node:fs";
526
+ import { existsSync, readFileSync as readFileSync2 } from "node:fs";
341
527
  import path2 from "node:path";
342
528
 
343
529
  // src/glob.ts
@@ -480,7 +666,7 @@ function parsePnpmPackages(content) {
480
666
  return result;
481
667
  }
482
668
  function readJson(file) {
483
- return JSON.parse(readFileSync(file, "utf8"));
669
+ return JSON.parse(readFileSync2(file, "utf8"));
484
670
  }
485
671
  function readJsonSafe(file) {
486
672
  try {
@@ -493,7 +679,7 @@ function readJsonSafe(file) {
493
679
  function detectPatterns(root) {
494
680
  const pnpmFile = path2.join(root, "pnpm-workspace.yaml");
495
681
  if (existsSync(pnpmFile)) {
496
- const patterns = parsePnpmPackages(readFileSync(pnpmFile, "utf8"));
682
+ const patterns = parsePnpmPackages(readFileSync2(pnpmFile, "utf8"));
497
683
  if (patterns.length > 0)
498
684
  return { source: "pnpm-workspace.yaml", patterns };
499
685
  }
@@ -564,7 +750,7 @@ function filterPackages(all, only, exclude) {
564
750
  });
565
751
  }
566
752
  // package.json
567
- var version = "0.2.2";
753
+ var version = "0.3.0";
568
754
 
569
755
  // src/cli.ts
570
756
  var VERSION = version;
@@ -578,11 +764,15 @@ Providers:
578
764
  github --repo <owner/repo> --file <workflow> [--env <name>]
579
765
  gitlab --project <group/project> --file <workflow> [--env <name>]
580
766
  (--repo is accepted as an alias for --project)
581
- circleci --vcs-origin <host/owner/repo> --circle-token <token> [--file <config>]
767
+ circleci [--vcs-origin <host/owner/repo>] [--circle-token <token>] [--file <config>]
582
768
  [--org-id <uuid>] [--project-id <uuid>] [--pipeline-definition-id <uuid>]
583
769
  [--context-id <uuid> ...]
584
- UUIDs are auto-resolved via CircleCI API v2 when --circle-token is provided.
585
- Explicit UUIDs override auto-resolution and work without a token.
770
+ --vcs-origin defaults to the root package.json "repository", then
771
+ \`git remote get-url origin\`.
772
+ Missing UUIDs are resolved via CircleCI API v2 using a token from
773
+ --circle-token, $CIRCLE_TOKEN, $CIRCLECI_TOKEN, $CIRCLE_CLI_TOKEN,
774
+ $CIRCLECI_CLI_TOKEN, or ~/.circleci/cli.yml (in that order).
775
+ Explicit UUIDs override auto-resolution; with all three, no token is needed.
586
776
 
587
777
  Permissions (at least one required):
588
778
  --allow-publish allow npm publish
@@ -603,8 +793,7 @@ as a single-package fallback. Private packages ("private": true) are always excl
603
793
  Examples:
604
794
  trustci --dry-run --provider github --repo me/repo --file release.yml --allow-publish
605
795
  trustci --provider gitlab --project me/repo --file .gitlab-ci.yml --allow-publish -y
606
- trustci --provider circleci --vcs-origin github.com/me/repo \\
607
- --circle-token $CIRCLECI_TOKEN --allow-publish
796
+ CIRCLE_TOKEN=... trustci --provider circleci --allow-publish
608
797
  trustci --provider circleci --org-id <uuid> --project-id <uuid> \\
609
798
  --pipeline-definition-id <uuid> --vcs-origin github.com/me/repo --allow-publish
610
799
  trustci list
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trustci",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Bulk-register npm Trusted Publishers (npm trust) across all public packages in a monorepo. Supports GitHub, GitLab and CircleCI.",
5
5
  "type": "module",
6
6
  "bin": {