trustci 0.1.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/LICENSE +21 -0
- package/README.md +86 -0
- package/dist/cli.js +636 -0
- package/package.json +38 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hidetaka Okamoto
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# trustci
|
|
2
|
+
|
|
3
|
+
Bulk-register [npm Trusted Publishers](https://docs.npmjs.com/trusted-publishers/)
|
|
4
|
+
across **all public packages in a monorepo** with a single command. Wraps
|
|
5
|
+
`npm trust` and supports **GitHub Actions**, **GitLab CI/CD**, and **CircleCI**.
|
|
6
|
+
|
|
7
|
+
Setting up trusted publishing one package at a time is tedious in a monorepo.
|
|
8
|
+
`trustci` detects your workspace, finds every public package, and runs the right
|
|
9
|
+
`npm trust <provider> …` for each.
|
|
10
|
+
|
|
11
|
+
## Requirements
|
|
12
|
+
|
|
13
|
+
- **npm** 11.5.1 or later (for `npm trust`)
|
|
14
|
+
- **Node** 22.14.0 or later
|
|
15
|
+
|
|
16
|
+
## Usage
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
# Preview the commands without running anything (recommended first step)
|
|
20
|
+
npx trustci --dry-run --provider github --repo me/repo --file release.yml --allow-publish
|
|
21
|
+
|
|
22
|
+
# Actually register (asks for confirmation unless -y)
|
|
23
|
+
npx trustci --provider github --repo me/repo --file release.yml --allow-publish
|
|
24
|
+
|
|
25
|
+
# List current trusted publishers for every public package
|
|
26
|
+
npx trustci list
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### Providers
|
|
30
|
+
|
|
31
|
+
| Provider | Required flags | Optional |
|
|
32
|
+
| ---------- | ---------------------------------------------------------------------------------------------- | --------------- |
|
|
33
|
+
| `github` | `--repo <owner/repo>` `--file <workflow>` | `--env <name>` |
|
|
34
|
+
| `gitlab` | `--project <group/project>` (or `--repo`) `--file <workflow>` | `--env <name>` |
|
|
35
|
+
| `circleci` | `--org-id` `--project-id` `--pipeline-definition-id` `--vcs-origin <url>` | `--context-id` (repeatable) |
|
|
36
|
+
|
|
37
|
+
At least one of `--allow-publish` / `--allow-stage-publish` is required.
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
# GitLab
|
|
41
|
+
npx trustci --provider gitlab --project me/repo --file .gitlab-ci.yml --allow-publish -y
|
|
42
|
+
|
|
43
|
+
# CircleCI (vcs-origin must include the protocol)
|
|
44
|
+
npx trustci --provider circleci \
|
|
45
|
+
--org-id <uuid> --project-id <uuid> --pipeline-definition-id <uuid> \
|
|
46
|
+
--vcs-origin https://github.com/me/repo \
|
|
47
|
+
--context-id <uuid> \
|
|
48
|
+
--allow-publish
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Common options
|
|
52
|
+
|
|
53
|
+
| Flag | Description |
|
|
54
|
+
| ------------------------ | --------------------------------------------------- |
|
|
55
|
+
| `--dry-run` | Print the `npm trust` commands without executing. |
|
|
56
|
+
| `--only <name-glob>` | Only include packages matching (repeatable). |
|
|
57
|
+
| `--exclude <name-glob>` | Exclude packages matching (repeatable). |
|
|
58
|
+
| `-y`, `--yes` | Skip the confirmation prompt (and npm's). |
|
|
59
|
+
| `-h`, `--help` | Show help. |
|
|
60
|
+
| `-v`, `--version` | Show version. |
|
|
61
|
+
|
|
62
|
+
## Workspace detection
|
|
63
|
+
|
|
64
|
+
Patterns are read from the first of:
|
|
65
|
+
|
|
66
|
+
1. `pnpm-workspace.yaml` (`packages:`)
|
|
67
|
+
2. `package.json` (`workspaces`)
|
|
68
|
+
3. `lerna.json` (`packages`)
|
|
69
|
+
|
|
70
|
+
Packages marked `"private": true` are always excluded. On a real (non-dry) run,
|
|
71
|
+
packages not yet published to the registry are skipped with a warning.
|
|
72
|
+
|
|
73
|
+
## Development
|
|
74
|
+
|
|
75
|
+
Built with [Bun](https://bun.sh), ships zero runtime dependencies, and the
|
|
76
|
+
published artifact is plain Node-compatible JS.
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
bun install
|
|
80
|
+
bun test
|
|
81
|
+
bun run build # -> dist/cli.js (npx entry point), via `bun build --target node`
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## License
|
|
85
|
+
|
|
86
|
+
MIT
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import process2 from "node:process";
|
|
5
|
+
import { createInterface } from "node:readline/promises";
|
|
6
|
+
|
|
7
|
+
// src/args.ts
|
|
8
|
+
import { parseArgs } from "node:util";
|
|
9
|
+
|
|
10
|
+
// src/types.ts
|
|
11
|
+
var PROVIDERS = ["github", "gitlab", "circleci"];
|
|
12
|
+
|
|
13
|
+
// src/args.ts
|
|
14
|
+
class ArgError extends Error {
|
|
15
|
+
}
|
|
16
|
+
var options = {
|
|
17
|
+
provider: { type: "string" },
|
|
18
|
+
repo: { type: "string" },
|
|
19
|
+
project: { type: "string" },
|
|
20
|
+
env: { type: "string" },
|
|
21
|
+
file: { type: "string" },
|
|
22
|
+
"org-id": { type: "string" },
|
|
23
|
+
"project-id": { type: "string" },
|
|
24
|
+
"pipeline-definition-id": { type: "string" },
|
|
25
|
+
"vcs-origin": { type: "string" },
|
|
26
|
+
"context-id": { type: "string", multiple: true },
|
|
27
|
+
"allow-publish": { type: "boolean" },
|
|
28
|
+
"allow-stage-publish": { type: "boolean" },
|
|
29
|
+
"dry-run": { type: "boolean" },
|
|
30
|
+
only: { type: "string", multiple: true },
|
|
31
|
+
exclude: { type: "string", multiple: true },
|
|
32
|
+
yes: { type: "boolean", short: "y" },
|
|
33
|
+
help: { type: "boolean", short: "h" },
|
|
34
|
+
version: { type: "boolean", short: "v" }
|
|
35
|
+
};
|
|
36
|
+
function require_(value, flag, provider) {
|
|
37
|
+
if (!value)
|
|
38
|
+
throw new ArgError(`--${flag} is required for --provider ${provider}.`);
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
function parseCliArgs(argv) {
|
|
42
|
+
let values;
|
|
43
|
+
let positionals;
|
|
44
|
+
try {
|
|
45
|
+
const parsed = parseArgs({ args: argv, options, allowPositionals: true, strict: true });
|
|
46
|
+
values = parsed.values;
|
|
47
|
+
positionals = parsed.positionals;
|
|
48
|
+
} catch (err) {
|
|
49
|
+
throw new ArgError(err.message);
|
|
50
|
+
}
|
|
51
|
+
if (values.help)
|
|
52
|
+
return { kind: "help" };
|
|
53
|
+
if (values.version)
|
|
54
|
+
return { kind: "version" };
|
|
55
|
+
const dryRun = values["dry-run"] === true;
|
|
56
|
+
const sub = positionals[0];
|
|
57
|
+
if (sub === "list") {
|
|
58
|
+
return { kind: "list", pkg: positionals[1], dryRun };
|
|
59
|
+
}
|
|
60
|
+
if (sub === "trust") {
|
|
61
|
+
if (positionals.length > 1) {
|
|
62
|
+
throw new ArgError(`Unexpected argument "${positionals[1]}" after "trust".`);
|
|
63
|
+
}
|
|
64
|
+
} else if (sub !== undefined) {
|
|
65
|
+
throw new ArgError(`Unknown command "${sub}". Did you mean "list" or "trust", or did you forget --provider?`);
|
|
66
|
+
}
|
|
67
|
+
const provider = values.provider;
|
|
68
|
+
if (!provider)
|
|
69
|
+
throw new ArgError("--provider is required (github | gitlab | circleci).");
|
|
70
|
+
if (!PROVIDERS.includes(provider)) {
|
|
71
|
+
throw new ArgError(`Unknown provider "${provider}". Must be one of: ${PROVIDERS.join(", ")}.`);
|
|
72
|
+
}
|
|
73
|
+
const common = {
|
|
74
|
+
allowPublish: values["allow-publish"] === true,
|
|
75
|
+
allowStagePublish: values["allow-stage-publish"] === true,
|
|
76
|
+
yes: values.yes === true
|
|
77
|
+
};
|
|
78
|
+
if (!common.allowPublish && !common.allowStagePublish) {
|
|
79
|
+
throw new ArgError("At least one of --allow-publish or --allow-stage-publish is required.");
|
|
80
|
+
}
|
|
81
|
+
let providerOptions;
|
|
82
|
+
switch (provider) {
|
|
83
|
+
case "github":
|
|
84
|
+
providerOptions = {
|
|
85
|
+
provider: "github",
|
|
86
|
+
repo: require_(values.repo, "repo", "github"),
|
|
87
|
+
file: require_(values.file, "file", "github"),
|
|
88
|
+
env: values.env,
|
|
89
|
+
...common
|
|
90
|
+
};
|
|
91
|
+
break;
|
|
92
|
+
case "gitlab": {
|
|
93
|
+
const project = values.project ?? values.repo;
|
|
94
|
+
providerOptions = {
|
|
95
|
+
provider: "gitlab",
|
|
96
|
+
project: require_(project, "project", "gitlab"),
|
|
97
|
+
file: require_(values.file, "file", "gitlab"),
|
|
98
|
+
env: values.env,
|
|
99
|
+
...common
|
|
100
|
+
};
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
case "circleci": {
|
|
104
|
+
const vcsOrigin = require_(values["vcs-origin"], "vcs-origin", "circleci");
|
|
105
|
+
if (!vcsOrigin.includes("://")) {
|
|
106
|
+
throw new ArgError(`--vcs-origin must include a protocol, e.g. https://github.com/org/repo (got "${vcsOrigin}").`);
|
|
107
|
+
}
|
|
108
|
+
providerOptions = {
|
|
109
|
+
provider: "circleci",
|
|
110
|
+
orgId: require_(values["org-id"], "org-id", "circleci"),
|
|
111
|
+
projectId: require_(values["project-id"], "project-id", "circleci"),
|
|
112
|
+
pipelineDefinitionId: require_(values["pipeline-definition-id"], "pipeline-definition-id", "circleci"),
|
|
113
|
+
vcsOrigin,
|
|
114
|
+
contextIds: values["context-id"] ?? [],
|
|
115
|
+
...common
|
|
116
|
+
};
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
kind: "trust",
|
|
122
|
+
options: providerOptions,
|
|
123
|
+
dryRun,
|
|
124
|
+
only: values.only ?? [],
|
|
125
|
+
exclude: values.exclude ?? []
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// src/exec.ts
|
|
130
|
+
import { spawnSync } from "node:child_process";
|
|
131
|
+
function run(cmd, args) {
|
|
132
|
+
const res = spawnSync(cmd, args, { encoding: "utf8" });
|
|
133
|
+
return {
|
|
134
|
+
status: res.status ?? (res.error || res.signal ? 1 : 0),
|
|
135
|
+
stdout: res.stdout ?? "",
|
|
136
|
+
stderr: res.stderr ?? ""
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
function runInherit(cmd, args) {
|
|
140
|
+
const res = spawnSync(cmd, args, { stdio: "inherit" });
|
|
141
|
+
return res.status ?? (res.error || res.signal ? 1 : 0);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// src/npm.ts
|
|
145
|
+
var MIN_NODE = "22.14.0";
|
|
146
|
+
var MIN_NPM = "11.5.1";
|
|
147
|
+
function cmpVersion(a, b) {
|
|
148
|
+
const pa = a.split(".").map((n) => parseInt(n, 10) || 0);
|
|
149
|
+
const pb = b.split(".").map((n) => parseInt(n, 10) || 0);
|
|
150
|
+
for (let i = 0;i < 3; i++) {
|
|
151
|
+
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
152
|
+
if (d !== 0)
|
|
153
|
+
return d > 0 ? 1 : -1;
|
|
154
|
+
}
|
|
155
|
+
return 0;
|
|
156
|
+
}
|
|
157
|
+
function getNpmVersion() {
|
|
158
|
+
const r = run("npm", ["--version"]);
|
|
159
|
+
if (r.status !== 0)
|
|
160
|
+
return null;
|
|
161
|
+
const v = r.stdout.trim();
|
|
162
|
+
return v || null;
|
|
163
|
+
}
|
|
164
|
+
function checkVersions() {
|
|
165
|
+
const node = process.versions.node;
|
|
166
|
+
if (cmpVersion(node, MIN_NODE) < 0) {
|
|
167
|
+
return `trustci requires Node ${MIN_NODE} or later (found ${node}).`;
|
|
168
|
+
}
|
|
169
|
+
const npm = getNpmVersion();
|
|
170
|
+
if (!npm) {
|
|
171
|
+
return "Could not determine the npm version. Is npm installed and on your PATH?";
|
|
172
|
+
}
|
|
173
|
+
if (cmpVersion(npm, MIN_NPM) < 0) {
|
|
174
|
+
return `trustci requires npm ${MIN_NPM} or later for trusted publishing (found ${npm}).
|
|
175
|
+
` + "Update with: npm install -g npm@latest";
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
function isPublished(name) {
|
|
180
|
+
const r = run("npm", ["view", name, "version"]);
|
|
181
|
+
if (r.status === 0) {
|
|
182
|
+
return r.stdout.trim().length > 0;
|
|
183
|
+
}
|
|
184
|
+
const out = `${r.stderr}
|
|
185
|
+
${r.stdout}`;
|
|
186
|
+
if (out.includes("E404") || out.includes("404 Not Found")) {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
throw new Error(`failed to check the registry for ${name}: ${r.stderr.trim() || "unknown error"}`);
|
|
190
|
+
}
|
|
191
|
+
function commonFlags(o) {
|
|
192
|
+
const a = [];
|
|
193
|
+
if (o.allowPublish)
|
|
194
|
+
a.push("--allow-publish");
|
|
195
|
+
if (o.allowStagePublish)
|
|
196
|
+
a.push("--allow-stage-publish");
|
|
197
|
+
if (o.yes)
|
|
198
|
+
a.push("-y");
|
|
199
|
+
return a;
|
|
200
|
+
}
|
|
201
|
+
function buildTrustArgs(o, pkg) {
|
|
202
|
+
const args = ["trust", o.provider, pkg];
|
|
203
|
+
switch (o.provider) {
|
|
204
|
+
case "github":
|
|
205
|
+
args.push("--repository", o.repo, "--file", o.file);
|
|
206
|
+
if (o.env)
|
|
207
|
+
args.push("--environment", o.env);
|
|
208
|
+
break;
|
|
209
|
+
case "gitlab":
|
|
210
|
+
args.push("--project", o.project, "--file", o.file);
|
|
211
|
+
if (o.env)
|
|
212
|
+
args.push("--environment", o.env);
|
|
213
|
+
break;
|
|
214
|
+
case "circleci":
|
|
215
|
+
args.push("--org-id", o.orgId, "--project-id", o.projectId, "--pipeline-definition-id", o.pipelineDefinitionId, "--vcs-origin", o.vcsOrigin);
|
|
216
|
+
for (const c of o.contextIds)
|
|
217
|
+
args.push("--context-id", c);
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
args.push(...commonFlags(o));
|
|
221
|
+
return args;
|
|
222
|
+
}
|
|
223
|
+
function shellQuote(s) {
|
|
224
|
+
return /^[A-Za-z0-9_@%/:.,=+-]+$/.test(s) ? s : `'${s.replace(/'/g, "'\\''")}'`;
|
|
225
|
+
}
|
|
226
|
+
function displayCommand(args) {
|
|
227
|
+
return ["npm", ...args].map(shellQuote).join(" ");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// src/workspace.ts
|
|
231
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
232
|
+
import path2 from "node:path";
|
|
233
|
+
|
|
234
|
+
// src/glob.ts
|
|
235
|
+
import { readdirSync } from "node:fs";
|
|
236
|
+
import path from "node:path";
|
|
237
|
+
var ALWAYS_IGNORE = new Set(["node_modules", ".git", "dist", "build", "coverage", ".turbo"]);
|
|
238
|
+
function escape(s) {
|
|
239
|
+
return s.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
240
|
+
}
|
|
241
|
+
function compilePathGlob(pattern) {
|
|
242
|
+
const segments = pattern.replace(/\/+$/, "").split("/");
|
|
243
|
+
const parts = [];
|
|
244
|
+
for (let i = 0;i < segments.length; i++) {
|
|
245
|
+
const seg = segments[i];
|
|
246
|
+
if (seg === "**") {
|
|
247
|
+
parts.push("(?:.*/)?");
|
|
248
|
+
if (i < segments.length - 1)
|
|
249
|
+
continue;
|
|
250
|
+
parts[parts.length - 1] = "(?:.*)?";
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
const segRe = escape(seg).replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]");
|
|
254
|
+
parts.push(segRe);
|
|
255
|
+
if (i < segments.length - 1)
|
|
256
|
+
parts.push("/");
|
|
257
|
+
}
|
|
258
|
+
let body = parts.join("").replace(/\(\?:\.\*\/\)\?\//g, "(?:.*/)?");
|
|
259
|
+
if (body.endsWith("/(?:.*)?")) {
|
|
260
|
+
body = `${body.slice(0, -"/(?:.*)?".length)}(?:/.*)?`;
|
|
261
|
+
}
|
|
262
|
+
return new RegExp(`^${body}$`);
|
|
263
|
+
}
|
|
264
|
+
function collectDirs(root) {
|
|
265
|
+
const out = [];
|
|
266
|
+
const walk = (abs, rel) => {
|
|
267
|
+
let entries;
|
|
268
|
+
try {
|
|
269
|
+
entries = readdirSync(abs, { withFileTypes: true });
|
|
270
|
+
} catch {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
for (const entry of entries) {
|
|
274
|
+
if (!entry.isDirectory())
|
|
275
|
+
continue;
|
|
276
|
+
if (ALWAYS_IGNORE.has(entry.name))
|
|
277
|
+
continue;
|
|
278
|
+
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
|
|
279
|
+
out.push(childRel);
|
|
280
|
+
walk(path.join(abs, entry.name), childRel);
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
walk(root, "");
|
|
284
|
+
return out;
|
|
285
|
+
}
|
|
286
|
+
function expandPatterns(root, patterns) {
|
|
287
|
+
const positives = [];
|
|
288
|
+
const negatives = [];
|
|
289
|
+
for (const raw of patterns) {
|
|
290
|
+
const pattern = raw.trim();
|
|
291
|
+
if (!pattern)
|
|
292
|
+
continue;
|
|
293
|
+
if (pattern.startsWith("!")) {
|
|
294
|
+
negatives.push(compilePathGlob(pattern.slice(1)));
|
|
295
|
+
} else {
|
|
296
|
+
positives.push(compilePathGlob(pattern));
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (positives.length === 0)
|
|
300
|
+
return [];
|
|
301
|
+
const dirs = collectDirs(root);
|
|
302
|
+
const matched = new Set;
|
|
303
|
+
for (const rel of dirs) {
|
|
304
|
+
if (positives.some((re) => re.test(rel)) && !negatives.some((re) => re.test(rel))) {
|
|
305
|
+
matched.add(rel);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return [...matched].sort().map((rel) => path.resolve(root, rel));
|
|
309
|
+
}
|
|
310
|
+
function matchName(pattern, name) {
|
|
311
|
+
const re = new RegExp(`^${escape(pattern).replace(/\*/g, ".*").replace(/\?/g, ".")}$`);
|
|
312
|
+
return re.test(name);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// src/workspace.ts
|
|
316
|
+
function stripComment(line) {
|
|
317
|
+
let inSingle = false;
|
|
318
|
+
let inDouble = false;
|
|
319
|
+
for (let i = 0;i < line.length; i++) {
|
|
320
|
+
const c = line[i];
|
|
321
|
+
if (c === "'" && !inDouble)
|
|
322
|
+
inSingle = !inSingle;
|
|
323
|
+
else if (c === '"' && !inSingle)
|
|
324
|
+
inDouble = !inDouble;
|
|
325
|
+
else if (c === "#" && !inSingle && !inDouble)
|
|
326
|
+
return line.slice(0, i);
|
|
327
|
+
}
|
|
328
|
+
return line;
|
|
329
|
+
}
|
|
330
|
+
function unquote(s) {
|
|
331
|
+
const t = s.trim();
|
|
332
|
+
if (t.length >= 2 && (t[0] === '"' && t.at(-1) === '"' || t[0] === "'" && t.at(-1) === "'")) {
|
|
333
|
+
return t.slice(1, -1);
|
|
334
|
+
}
|
|
335
|
+
return t;
|
|
336
|
+
}
|
|
337
|
+
function parseFlowArray(s) {
|
|
338
|
+
const open = s.indexOf("[");
|
|
339
|
+
const close = s.lastIndexOf("]");
|
|
340
|
+
if (open === -1 || close === -1)
|
|
341
|
+
return [];
|
|
342
|
+
return s.slice(open + 1, close).split(",").map((x) => unquote(x)).filter(Boolean);
|
|
343
|
+
}
|
|
344
|
+
function parsePnpmPackages(content) {
|
|
345
|
+
const lines = content.split(/\r?\n/);
|
|
346
|
+
const result = [];
|
|
347
|
+
let inBlock = false;
|
|
348
|
+
for (const rawLine of lines) {
|
|
349
|
+
const line = stripComment(rawLine);
|
|
350
|
+
const trimmed = line.trim();
|
|
351
|
+
if (!trimmed)
|
|
352
|
+
continue;
|
|
353
|
+
if (!inBlock) {
|
|
354
|
+
const m = /^packages\s*:\s*(.*)$/.exec(trimmed);
|
|
355
|
+
if (!m)
|
|
356
|
+
continue;
|
|
357
|
+
const rest = (m[1] ?? "").trim();
|
|
358
|
+
if (rest.startsWith("["))
|
|
359
|
+
return parseFlowArray(rest);
|
|
360
|
+
inBlock = true;
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
if (trimmed.startsWith("-")) {
|
|
364
|
+
const val = unquote(trimmed.slice(1).trim());
|
|
365
|
+
if (val)
|
|
366
|
+
result.push(val);
|
|
367
|
+
} else {
|
|
368
|
+
break;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return result;
|
|
372
|
+
}
|
|
373
|
+
function readJson(file) {
|
|
374
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
375
|
+
}
|
|
376
|
+
function readJsonSafe(file) {
|
|
377
|
+
try {
|
|
378
|
+
const json = readJson(file);
|
|
379
|
+
return json && typeof json === "object" ? json : null;
|
|
380
|
+
} catch {
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
function detectPatterns(root) {
|
|
385
|
+
const pnpmFile = path2.join(root, "pnpm-workspace.yaml");
|
|
386
|
+
if (existsSync(pnpmFile)) {
|
|
387
|
+
const patterns = parsePnpmPackages(readFileSync(pnpmFile, "utf8"));
|
|
388
|
+
if (patterns.length > 0)
|
|
389
|
+
return { source: "pnpm-workspace.yaml", patterns };
|
|
390
|
+
}
|
|
391
|
+
const pkgFile = path2.join(root, "package.json");
|
|
392
|
+
if (existsSync(pkgFile)) {
|
|
393
|
+
const json = readJsonSafe(pkgFile);
|
|
394
|
+
if (json) {
|
|
395
|
+
const ws = json.workspaces;
|
|
396
|
+
const patterns = Array.isArray(ws) ? ws : Array.isArray(ws?.packages) ? ws.packages : [];
|
|
397
|
+
if (patterns.length > 0)
|
|
398
|
+
return { source: "package.json (workspaces)", patterns };
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
const lernaFile = path2.join(root, "lerna.json");
|
|
402
|
+
if (existsSync(lernaFile)) {
|
|
403
|
+
const json = readJsonSafe(lernaFile);
|
|
404
|
+
if (json) {
|
|
405
|
+
const patterns = Array.isArray(json.packages) ? json.packages : ["packages/*"];
|
|
406
|
+
if (patterns.length > 0)
|
|
407
|
+
return { source: "lerna.json", patterns };
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return { source: "none", patterns: [] };
|
|
411
|
+
}
|
|
412
|
+
function readPkg(dir) {
|
|
413
|
+
const file = path2.join(dir, "package.json");
|
|
414
|
+
if (!existsSync(file))
|
|
415
|
+
return null;
|
|
416
|
+
const json = readJsonSafe(file);
|
|
417
|
+
if (!json || typeof json.name !== "string" || !json.name)
|
|
418
|
+
return null;
|
|
419
|
+
return {
|
|
420
|
+
name: json.name,
|
|
421
|
+
version: typeof json.version === "string" ? json.version : undefined,
|
|
422
|
+
private: json.private === true,
|
|
423
|
+
dir
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
function discoverPackages(root) {
|
|
427
|
+
const { source, patterns } = detectPatterns(root);
|
|
428
|
+
const dirs = expandPatterns(root, patterns);
|
|
429
|
+
const all = [];
|
|
430
|
+
const seen = new Set;
|
|
431
|
+
for (const dir of dirs) {
|
|
432
|
+
const pkg = readPkg(dir);
|
|
433
|
+
if (pkg && !seen.has(pkg.name)) {
|
|
434
|
+
seen.add(pkg.name);
|
|
435
|
+
all.push(pkg);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return { source, patterns, all };
|
|
439
|
+
}
|
|
440
|
+
function filterPackages(all, only, exclude) {
|
|
441
|
+
return all.filter((pkg) => {
|
|
442
|
+
if (pkg.private)
|
|
443
|
+
return false;
|
|
444
|
+
if (only.length > 0 && !only.some((p) => matchName(p, pkg.name)))
|
|
445
|
+
return false;
|
|
446
|
+
if (exclude.some((p) => matchName(p, pkg.name)))
|
|
447
|
+
return false;
|
|
448
|
+
return true;
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// src/cli.ts
|
|
453
|
+
var VERSION = "0.1.0";
|
|
454
|
+
var HELP = `trustci — bulk-register npm Trusted Publishers across a monorepo
|
|
455
|
+
|
|
456
|
+
Usage:
|
|
457
|
+
trustci [trust] --provider <github|gitlab|circleci> [provider flags] [options]
|
|
458
|
+
trustci list [package] [--dry-run]
|
|
459
|
+
|
|
460
|
+
Providers:
|
|
461
|
+
github --repo <owner/repo> --file <workflow> [--env <name>]
|
|
462
|
+
gitlab --project <group/project> --file <workflow> [--env <name>]
|
|
463
|
+
(--repo is accepted as an alias for --project)
|
|
464
|
+
circleci --org-id <uuid> --project-id <uuid> --pipeline-definition-id <uuid>
|
|
465
|
+
--vcs-origin <url> [--context-id <uuid> ...]
|
|
466
|
+
|
|
467
|
+
Permissions (at least one required):
|
|
468
|
+
--allow-publish allow npm publish
|
|
469
|
+
--allow-stage-publish allow npm stage publish
|
|
470
|
+
|
|
471
|
+
Options:
|
|
472
|
+
--dry-run print the npm trust commands without executing them
|
|
473
|
+
--only <name-glob> only include packages matching (repeatable)
|
|
474
|
+
--exclude <name-glob> exclude packages matching (repeatable)
|
|
475
|
+
-y, --yes skip the confirmation prompt (and npm's)
|
|
476
|
+
-h, --help show this help
|
|
477
|
+
-v, --version show the trustci version
|
|
478
|
+
|
|
479
|
+
Workspaces are detected from pnpm-workspace.yaml, package.json "workspaces",
|
|
480
|
+
or lerna.json. Private packages ("private": true) are always excluded.
|
|
481
|
+
|
|
482
|
+
Examples:
|
|
483
|
+
trustci --dry-run --provider github --repo me/repo --file release.yml --allow-publish
|
|
484
|
+
trustci --provider gitlab --project me/repo --file .gitlab-ci.yml --allow-publish -y
|
|
485
|
+
trustci --provider circleci --org-id <uuid> --project-id <uuid> \\
|
|
486
|
+
--pipeline-definition-id <uuid> --vcs-origin https://github.com/me/repo --allow-publish
|
|
487
|
+
trustci list
|
|
488
|
+
`;
|
|
489
|
+
async function confirm(question) {
|
|
490
|
+
const rl = createInterface({ input: process2.stdin, output: process2.stderr });
|
|
491
|
+
try {
|
|
492
|
+
const answer = (await rl.question(`${question} [y/N] `)).trim().toLowerCase();
|
|
493
|
+
return answer === "y" || answer === "yes";
|
|
494
|
+
} finally {
|
|
495
|
+
rl.close();
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
async function runList(pkg, dryRun) {
|
|
499
|
+
const targets = [];
|
|
500
|
+
if (pkg) {
|
|
501
|
+
targets.push(pkg);
|
|
502
|
+
} else {
|
|
503
|
+
const { all, source } = discoverPackages(process2.cwd());
|
|
504
|
+
const pub = filterPackages(all, [], []);
|
|
505
|
+
if (pub.length === 0) {
|
|
506
|
+
console.error(`No public workspace packages found (detected via ${source}).`);
|
|
507
|
+
return 1;
|
|
508
|
+
}
|
|
509
|
+
targets.push(...pub.map((p) => p.name));
|
|
510
|
+
}
|
|
511
|
+
let failures = 0;
|
|
512
|
+
for (const name of targets) {
|
|
513
|
+
const args = ["trust", "list", name];
|
|
514
|
+
if (dryRun) {
|
|
515
|
+
console.log(displayCommand(args));
|
|
516
|
+
} else {
|
|
517
|
+
console.error(`
|
|
518
|
+
$ ${displayCommand(args)}`);
|
|
519
|
+
if (runInherit("npm", args) !== 0)
|
|
520
|
+
failures++;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
return failures > 0 ? 1 : 0;
|
|
524
|
+
}
|
|
525
|
+
async function runTrust(parsed) {
|
|
526
|
+
const root = process2.cwd();
|
|
527
|
+
const { all, source } = discoverPackages(root);
|
|
528
|
+
if (all.length === 0) {
|
|
529
|
+
console.error(`No workspace packages found (detected via ${source}).`);
|
|
530
|
+
return 1;
|
|
531
|
+
}
|
|
532
|
+
const candidates = filterPackages(all, parsed.only, parsed.exclude);
|
|
533
|
+
if (candidates.length === 0) {
|
|
534
|
+
console.error(`No public packages matched (out of ${all.length} discovered via ${source}).`);
|
|
535
|
+
return 1;
|
|
536
|
+
}
|
|
537
|
+
console.error(`Detected ${all.length} package(s) via ${source}; ${candidates.length} public after filters.`);
|
|
538
|
+
const provider = parsed.options.provider;
|
|
539
|
+
if (parsed.dryRun) {
|
|
540
|
+
console.log(`
|
|
541
|
+
# ${candidates.length} command(s) for provider "${provider}" (dry run):
|
|
542
|
+
`);
|
|
543
|
+
for (const pkg of candidates) {
|
|
544
|
+
console.log(displayCommand(buildTrustArgs(parsed.options, pkg.name)));
|
|
545
|
+
}
|
|
546
|
+
console.error(`
|
|
547
|
+
(dry run — registry publish-state was not checked and nothing was executed)`);
|
|
548
|
+
return 0;
|
|
549
|
+
}
|
|
550
|
+
const targets = [];
|
|
551
|
+
for (const pkg of candidates) {
|
|
552
|
+
try {
|
|
553
|
+
if (isPublished(pkg.name)) {
|
|
554
|
+
targets.push(pkg);
|
|
555
|
+
} else {
|
|
556
|
+
console.warn(`skip ${pkg.name}: not published to the registry yet`);
|
|
557
|
+
}
|
|
558
|
+
} catch (err) {
|
|
559
|
+
console.error(`error: ${err.message}`);
|
|
560
|
+
return 1;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
if (targets.length === 0) {
|
|
564
|
+
console.error("Nothing to do: no published packages to register.");
|
|
565
|
+
return 1;
|
|
566
|
+
}
|
|
567
|
+
console.error(`
|
|
568
|
+
Will register ${targets.length} package(s) with ${provider}:`);
|
|
569
|
+
for (const pkg of targets)
|
|
570
|
+
console.error(` - ${pkg.name}`);
|
|
571
|
+
if (!parsed.options.yes) {
|
|
572
|
+
if (!process2.stdin.isTTY) {
|
|
573
|
+
console.error("error: confirmation required but stdin is not a TTY. Pass --yes (-y) to run non-interactively.");
|
|
574
|
+
return 1;
|
|
575
|
+
}
|
|
576
|
+
if (!await confirm(`
|
|
577
|
+
Proceed?`)) {
|
|
578
|
+
console.error("Aborted.");
|
|
579
|
+
return 1;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
let failures = 0;
|
|
583
|
+
for (const pkg of targets) {
|
|
584
|
+
const args = buildTrustArgs(parsed.options, pkg.name);
|
|
585
|
+
console.error(`
|
|
586
|
+
$ ${displayCommand(args)}`);
|
|
587
|
+
const code = runInherit("npm", args);
|
|
588
|
+
if (code !== 0) {
|
|
589
|
+
failures++;
|
|
590
|
+
console.error(` ${pkg.name}: failed (exit ${code})`);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
console.error(`
|
|
594
|
+
Done. ${targets.length - failures} succeeded, ${failures} failed.`);
|
|
595
|
+
return failures > 0 ? 1 : 0;
|
|
596
|
+
}
|
|
597
|
+
async function main() {
|
|
598
|
+
let parsed;
|
|
599
|
+
try {
|
|
600
|
+
parsed = parseCliArgs(process2.argv.slice(2));
|
|
601
|
+
} catch (err) {
|
|
602
|
+
if (err instanceof ArgError) {
|
|
603
|
+
console.error(`error: ${err.message}
|
|
604
|
+
`);
|
|
605
|
+
console.error(HELP);
|
|
606
|
+
return 1;
|
|
607
|
+
}
|
|
608
|
+
throw err;
|
|
609
|
+
}
|
|
610
|
+
if (parsed.kind === "help") {
|
|
611
|
+
console.log(HELP);
|
|
612
|
+
return 0;
|
|
613
|
+
}
|
|
614
|
+
if (parsed.kind === "version") {
|
|
615
|
+
console.log(VERSION);
|
|
616
|
+
return 0;
|
|
617
|
+
}
|
|
618
|
+
const verr = checkVersions();
|
|
619
|
+
if (verr) {
|
|
620
|
+
if (parsed.dryRun) {
|
|
621
|
+
console.error(`warning: ${verr}
|
|
622
|
+
(continuing because --dry-run does not execute npm)
|
|
623
|
+
`);
|
|
624
|
+
} else {
|
|
625
|
+
console.error(`error: ${verr}`);
|
|
626
|
+
return 1;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
return parsed.kind === "list" ? runList(parsed.pkg, parsed.dryRun) : runTrust(parsed);
|
|
630
|
+
}
|
|
631
|
+
main().then((code) => {
|
|
632
|
+
process2.exitCode = code;
|
|
633
|
+
}, (err) => {
|
|
634
|
+
console.error(err);
|
|
635
|
+
process2.exitCode = 1;
|
|
636
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "trustci",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Bulk-register npm Trusted Publishers (npm trust) across all public packages in a monorepo. Supports GitHub, GitLab and CircleCI.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"trustci": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=22.14.0",
|
|
14
|
+
"npm": ">=11.5.1"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "bun build src/cli.ts --target node --outfile dist/cli.js",
|
|
18
|
+
"test": "bun test",
|
|
19
|
+
"prepublishOnly": "bun run build"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"npm",
|
|
23
|
+
"trusted-publishing",
|
|
24
|
+
"trusted-publisher",
|
|
25
|
+
"oidc",
|
|
26
|
+
"monorepo",
|
|
27
|
+
"cli",
|
|
28
|
+
"npm-trust",
|
|
29
|
+
"github-actions",
|
|
30
|
+
"gitlab",
|
|
31
|
+
"circleci"
|
|
32
|
+
],
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/bun": "latest",
|
|
36
|
+
"typescript": "^5"
|
|
37
|
+
}
|
|
38
|
+
}
|