@tapestry-mud/cli 0.5.0 → 0.7.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 +17 -6
- package/bin/tapestry.js +78 -7
- package/package.json +1 -1
- package/src/commands/change-password.js +2 -2
- package/src/commands/dist-tag.js +2 -2
- package/src/commands/install.js +3 -3
- package/src/commands/link.js +64 -7
- package/src/commands/login.js +4 -9
- package/src/commands/logout.js +19 -0
- package/src/commands/preset.js +11 -4
- package/src/commands/publish.js +18 -3
- package/src/commands/register.js +3 -3
- package/src/commands/trust.js +42 -0
- package/src/commands/unpublish.js +2 -2
- package/src/lib/auth.js +60 -10
- package/src/lib/links.js +28 -1
- package/src/lib/oidc.js +26 -0
- package/src/lib/registry-client.js +65 -1
package/README.md
CHANGED
|
@@ -83,7 +83,11 @@ my-game/
|
|
|
83
83
|
| `tapestry search [query]` | Search the registry by keyword |
|
|
84
84
|
| `tapestry info [pack]` | Show pack metadata from the registry |
|
|
85
85
|
| `tapestry register` | Create a registry account |
|
|
86
|
-
| `tapestry login` | Authenticate with the registry |
|
|
86
|
+
| `tapestry login` | Authenticate with the registry (interactive password login) |
|
|
87
|
+
| `tapestry logout` | Revoke your session and remove `~/.tapestryrc` |
|
|
88
|
+
| `tapestry trust add <scope> <repo>` | Authorize a GitHub repo to publish to a scope via OIDC |
|
|
89
|
+
| `tapestry trust list` | List your trusted publishers |
|
|
90
|
+
| `tapestry trust rm <id>` | Remove a trusted publisher binding |
|
|
87
91
|
| `tapestry change-password` | Change your registry password |
|
|
88
92
|
|
|
89
93
|
### Pack Authoring
|
|
@@ -140,16 +144,23 @@ tapestry dist-tag set @yourscope/my-pack stable 0.1.0
|
|
|
140
144
|
|
|
141
145
|
Players using `tapestry init` with a preset that references your pack will resolve to the tagged version.
|
|
142
146
|
|
|
143
|
-
### CI Publishing
|
|
147
|
+
### CI Publishing (GitHub Actions, OIDC)
|
|
144
148
|
|
|
145
|
-
|
|
149
|
+
`tapestry publish` auto-detects the GitHub Actions OIDC environment and exchanges a short-lived
|
|
150
|
+
id-token for a registry access token — no stored secret, no `tapestry login` step. In your workflow:
|
|
151
|
+
|
|
152
|
+
```yaml
|
|
153
|
+
permissions:
|
|
154
|
+
id-token: write # required for OIDC
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Authorize the repo once (scope owner or admin):
|
|
146
158
|
|
|
147
159
|
```bash
|
|
148
|
-
tapestry
|
|
149
|
-
tapestry publish
|
|
160
|
+
tapestry trust add yourscope your-org/your-repo
|
|
150
161
|
```
|
|
151
162
|
|
|
152
|
-
|
|
163
|
+
Then `tapestry publish` in CI just works. There is no `REGISTRY_CI_TOKEN` and no `--token` flag.
|
|
153
164
|
|
|
154
165
|
## Registry
|
|
155
166
|
|
package/bin/tapestry.js
CHANGED
|
@@ -11,6 +11,7 @@ const { update } = require('../src/commands/update');
|
|
|
11
11
|
const { enable } = require('../src/commands/enable');
|
|
12
12
|
const { disable } = require('../src/commands/disable');
|
|
13
13
|
const { login } = require('../src/commands/login');
|
|
14
|
+
const { logout } = require('../src/commands/logout');
|
|
14
15
|
const { register } = require('../src/commands/register');
|
|
15
16
|
const { validate } = require('../src/commands/validate');
|
|
16
17
|
const { pack } = require('../src/commands/pack');
|
|
@@ -27,7 +28,8 @@ const { stopCmd } = require('../src/commands/stop');
|
|
|
27
28
|
const { changePassword } = require('../src/commands/change-password');
|
|
28
29
|
const { unpublish } = require('../src/commands/unpublish');
|
|
29
30
|
const { distTagSet, distTagList } = require('../src/commands/dist-tag');
|
|
30
|
-
const { presetSet } = require('../src/commands/preset');
|
|
31
|
+
const { presetSet, presetDelete } = require('../src/commands/preset');
|
|
32
|
+
const { trustAdd, trustList, trustRm } = require('../src/commands/trust');
|
|
31
33
|
|
|
32
34
|
const program = new Command();
|
|
33
35
|
|
|
@@ -53,12 +55,16 @@ program.configureHelp({
|
|
|
53
55
|
},
|
|
54
56
|
{
|
|
55
57
|
title: 'Account',
|
|
56
|
-
commands: ['register', 'login', 'change-password'],
|
|
58
|
+
commands: ['register', 'login', 'logout', 'change-password'],
|
|
57
59
|
},
|
|
58
60
|
{
|
|
59
61
|
title: 'Pack Authoring',
|
|
60
62
|
commands: ['create', 'validate', 'pack', 'publish', 'unpublish'],
|
|
61
63
|
},
|
|
64
|
+
{
|
|
65
|
+
title: 'Trusted Publishing',
|
|
66
|
+
commands: ['trust'],
|
|
67
|
+
},
|
|
62
68
|
{
|
|
63
69
|
title: 'Admin',
|
|
64
70
|
commands: ['dist-tag', 'preset'],
|
|
@@ -169,6 +175,18 @@ presetCmd
|
|
|
169
175
|
}
|
|
170
176
|
});
|
|
171
177
|
|
|
178
|
+
presetCmd
|
|
179
|
+
.command('delete <name>')
|
|
180
|
+
.description('Delete a preset from the registry')
|
|
181
|
+
.action(async (name) => {
|
|
182
|
+
try {
|
|
183
|
+
await presetDelete(name);
|
|
184
|
+
} catch (e) {
|
|
185
|
+
console.error(`error: ${e.message}`);
|
|
186
|
+
process.exit(1);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
172
190
|
program
|
|
173
191
|
.command('install [package]')
|
|
174
192
|
.description('Install a package or all dependencies from tapestry.yaml')
|
|
@@ -231,11 +249,22 @@ program
|
|
|
231
249
|
|
|
232
250
|
program
|
|
233
251
|
.command('login')
|
|
234
|
-
.description('Authenticate with the registry
|
|
235
|
-
.
|
|
236
|
-
.action(async (options) => {
|
|
252
|
+
.description('Authenticate with the registry (interactive password login)')
|
|
253
|
+
.action(async () => {
|
|
237
254
|
try {
|
|
238
|
-
await login(
|
|
255
|
+
await login();
|
|
256
|
+
} catch (e) {
|
|
257
|
+
console.error(`error: ${e.message}`);
|
|
258
|
+
process.exit(1);
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
program
|
|
263
|
+
.command('logout')
|
|
264
|
+
.description('Revoke your session and remove ~/.tapestryrc')
|
|
265
|
+
.action(async () => {
|
|
266
|
+
try {
|
|
267
|
+
await logout();
|
|
239
268
|
} catch (e) {
|
|
240
269
|
console.error(`error: ${e.message}`);
|
|
241
270
|
process.exit(1);
|
|
@@ -254,6 +283,47 @@ program
|
|
|
254
283
|
}
|
|
255
284
|
});
|
|
256
285
|
|
|
286
|
+
const trustCmd = program.command('trust').description('Manage trusted publishers (OIDC CI publishing)');
|
|
287
|
+
|
|
288
|
+
trustCmd
|
|
289
|
+
.command('add <scope> <repo>')
|
|
290
|
+
.description('Authorize a GitHub repo (owner/name) to publish to a scope via OIDC')
|
|
291
|
+
.option('--ref <ref>', 'Restrict to a git ref, e.g. refs/heads/master')
|
|
292
|
+
.option('--environment <env>', 'Restrict to a GitHub Actions environment')
|
|
293
|
+
.action(async (scope, repo, options) => {
|
|
294
|
+
try {
|
|
295
|
+
await trustAdd(scope, repo, { ref: options.ref, environment: options.environment });
|
|
296
|
+
} catch (e) {
|
|
297
|
+
console.error(`error: ${e.message}`);
|
|
298
|
+
process.exit(1);
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
trustCmd
|
|
303
|
+
.command('list')
|
|
304
|
+
.description('List trusted publishers you own (all, if admin)')
|
|
305
|
+
.option('--scope <scope>', 'Filter by scope')
|
|
306
|
+
.action(async (options) => {
|
|
307
|
+
try {
|
|
308
|
+
await trustList(options.scope);
|
|
309
|
+
} catch (e) {
|
|
310
|
+
console.error(`error: ${e.message}`);
|
|
311
|
+
process.exit(1);
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
trustCmd
|
|
316
|
+
.command('rm <id>')
|
|
317
|
+
.description('Remove a trusted publisher binding by id')
|
|
318
|
+
.action(async (id) => {
|
|
319
|
+
try {
|
|
320
|
+
await trustRm(id);
|
|
321
|
+
} catch (e) {
|
|
322
|
+
console.error(`error: ${e.message}`);
|
|
323
|
+
process.exit(1);
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
|
|
257
327
|
program
|
|
258
328
|
.command('validate')
|
|
259
329
|
.description('Validate pack.yaml in the current directory')
|
|
@@ -342,12 +412,13 @@ program
|
|
|
342
412
|
.command('link [path]')
|
|
343
413
|
.description('Attach a local pack working copy to this project (use --list to show links)')
|
|
344
414
|
.option('--list', 'List active links instead of creating one')
|
|
415
|
+
.option('--skip-install', 'Skip dependency resolution; warn about missing deps instead')
|
|
345
416
|
.action(async (linkPath, options) => {
|
|
346
417
|
try {
|
|
347
418
|
if (options.list || !linkPath) {
|
|
348
419
|
await linkList();
|
|
349
420
|
} else {
|
|
350
|
-
await link(linkPath);
|
|
421
|
+
await link(linkPath, { noInstall: !!options.skipInstall });
|
|
351
422
|
}
|
|
352
423
|
} catch (e) {
|
|
353
424
|
console.error(`error: ${e.message}`);
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const fetch = require('node-fetch');
|
|
4
|
-
const {
|
|
4
|
+
const { requireAccess } = require('../lib/auth');
|
|
5
5
|
const { DEFAULT_REGISTRY, throwIfError } = require('../lib/registry-client');
|
|
6
6
|
const { createInterface, askPassword } = require('../util/prompt');
|
|
7
7
|
|
|
8
8
|
async function changePassword({ registryUrl = DEFAULT_REGISTRY } = {}) {
|
|
9
|
-
const token =
|
|
9
|
+
const token = await requireAccess();
|
|
10
10
|
const rl = createInterface();
|
|
11
11
|
try {
|
|
12
12
|
const currentPassword = await askPassword(rl, 'Current password: ');
|
package/src/commands/dist-tag.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const {
|
|
3
|
+
const { requireAccess } = require('../lib/auth');
|
|
4
4
|
const { patchDistTag, listDistTags, DEFAULT_REGISTRY } = require('../lib/registry-client');
|
|
5
5
|
|
|
6
6
|
async function distTagSet(packName, tag, version, { registryUrl = DEFAULT_REGISTRY } = {}) {
|
|
7
|
-
const token =
|
|
7
|
+
const token = await requireAccess();
|
|
8
8
|
await patchDistTag(packName, tag, version, token, registryUrl);
|
|
9
9
|
console.log(` ${packName} ${tag} -> ${version}`);
|
|
10
10
|
console.log('Done.');
|
package/src/commands/install.js
CHANGED
|
@@ -9,7 +9,7 @@ const { readLock, writeLock, hashDeps } = require('../lib/lock-file');
|
|
|
9
9
|
const { fetchTarball, DEFAULT_REGISTRY } = require('../lib/registry-client');
|
|
10
10
|
const { verifyIntegrity, saveTarball, extractTarball } = require('../lib/tarball');
|
|
11
11
|
const { addPackageToBoot } = require('../lib/boot');
|
|
12
|
-
const {
|
|
12
|
+
const { loadAccess } = require('../lib/auth');
|
|
13
13
|
const { PACK_MANIFEST } = require('../lib/manifest');
|
|
14
14
|
const { readLinks } = require('../lib/links');
|
|
15
15
|
|
|
@@ -85,7 +85,7 @@ async function install(packageArg, { cwd = process.cwd(), registryUrl = DEFAULT_
|
|
|
85
85
|
throw new Error('No tapestry.yaml found. Run `tapestry init` first.');
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
const token =
|
|
88
|
+
const token = await loadAccess();
|
|
89
89
|
const manifest = readYaml(manifestPath);
|
|
90
90
|
let resolved;
|
|
91
91
|
|
|
@@ -129,4 +129,4 @@ async function install(packageArg, { cwd = process.cwd(), registryUrl = DEFAULT_
|
|
|
129
129
|
console.log('Done.');
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
-
module.exports = { install };
|
|
132
|
+
module.exports = { install, installResolved, packInstallPath };
|
package/src/commands/link.js
CHANGED
|
@@ -4,9 +4,14 @@ const fs = require('fs');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const {
|
|
6
6
|
readLinks, addLink, removeLink, readPackManifest,
|
|
7
|
-
removeMaterializedLink, checkMissingDeps,
|
|
7
|
+
removeMaterializedLink, checkMissingDeps, partitionDeps,
|
|
8
8
|
} = require('../lib/links');
|
|
9
9
|
const { addPackageToBoot, removePackageFromBoot } = require('../lib/boot');
|
|
10
|
+
const { resolve } = require('../lib/semver-resolver');
|
|
11
|
+
const { installResolved, packInstallPath } = require('./install');
|
|
12
|
+
const { readLock, writeLock } = require('../lib/lock-file');
|
|
13
|
+
const { loadAccess } = require('../lib/auth');
|
|
14
|
+
const { DEFAULT_REGISTRY } = require('../lib/registry-client');
|
|
10
15
|
|
|
11
16
|
function requireProject(cwd) {
|
|
12
17
|
if (!fs.existsSync(path.join(cwd, 'tapestry.yaml'))) {
|
|
@@ -25,7 +30,7 @@ function ensureGitignore(cwd) {
|
|
|
25
30
|
}
|
|
26
31
|
}
|
|
27
32
|
|
|
28
|
-
async function link(targetPath, { cwd = process.cwd() } = {}) {
|
|
33
|
+
async function link(targetPath, { cwd = process.cwd(), noInstall = false, registryUrl = DEFAULT_REGISTRY } = {}) {
|
|
29
34
|
requireProject(cwd);
|
|
30
35
|
const absPath = path.resolve(cwd, targetPath);
|
|
31
36
|
if (!fs.existsSync(absPath)) {
|
|
@@ -41,14 +46,66 @@ async function link(targetPath, { cwd = process.cwd() } = {}) {
|
|
|
41
46
|
addPackageToBoot(cwd, name, manifest);
|
|
42
47
|
ensureGitignore(cwd);
|
|
43
48
|
|
|
44
|
-
|
|
45
|
-
|
|
49
|
+
// Warn if the pack is marked inactive — fires on all paths
|
|
46
50
|
if (manifest.active === false) {
|
|
47
51
|
console.warn(` warning: ${name} is marked active: false; it will not load until activated`);
|
|
48
52
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
console.
|
|
53
|
+
|
|
54
|
+
if (noInstall) {
|
|
55
|
+
console.log(`linked ${name} -> ${absPath}`);
|
|
56
|
+
for (const dep of checkMissingDeps(cwd, manifest)) {
|
|
57
|
+
const range = manifest.dependencies[dep];
|
|
58
|
+
console.warn(` warning: missing dependency ${dep} (${range}) -- run: tapestry install ${dep}`);
|
|
59
|
+
}
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const { needsInstall } = partitionDeps(cwd, manifest);
|
|
64
|
+
|
|
65
|
+
if (Object.keys(needsInstall).length === 0) {
|
|
66
|
+
console.log(`linked ${name} -> ${absPath}`);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let toRollback = [];
|
|
71
|
+
try {
|
|
72
|
+
const token = await loadAccess();
|
|
73
|
+
const resolved = await resolve(needsInstall, registryUrl, token);
|
|
74
|
+
|
|
75
|
+
// New installs: not on disk yet. Upgrade targets: in needsInstall AND on disk
|
|
76
|
+
// (installResolved deletes the old dir before downloading; track so rollback removes the boot entry)
|
|
77
|
+
toRollback = [
|
|
78
|
+
...Object.keys(resolved).filter((n) => !fs.existsSync(packInstallPath(cwd, n))),
|
|
79
|
+
...Object.keys(needsInstall).filter((n) => fs.existsSync(packInstallPath(cwd, n))),
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
await installResolved(cwd, resolved, token);
|
|
83
|
+
|
|
84
|
+
const existingLock = readLock(cwd);
|
|
85
|
+
const mergedResolved = Object.assign({}, (existingLock && existingLock.resolved) || {}, resolved);
|
|
86
|
+
writeLock(cwd, {
|
|
87
|
+
lockfile_version: 1,
|
|
88
|
+
...(existingLock && existingLock.deps_hash ? { deps_hash: existingLock.deps_hash } : {}),
|
|
89
|
+
resolved: mergedResolved,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
console.log(`linked ${name} -> ${absPath}`);
|
|
93
|
+
for (const [pkgName, info] of Object.entries(resolved)) {
|
|
94
|
+
console.log(` installed ${pkgName}@${info.version} (dependency of ${name})`);
|
|
95
|
+
}
|
|
96
|
+
} catch (err) {
|
|
97
|
+
removeLink(cwd, name);
|
|
98
|
+
removePackageFromBoot(cwd, name);
|
|
99
|
+
for (const pkgName of toRollback) {
|
|
100
|
+
const installPath = packInstallPath(cwd, pkgName);
|
|
101
|
+
if (fs.existsSync(installPath)) {
|
|
102
|
+
fs.rmSync(installPath, { recursive: true });
|
|
103
|
+
}
|
|
104
|
+
removePackageFromBoot(cwd, pkgName);
|
|
105
|
+
}
|
|
106
|
+
throw new Error(
|
|
107
|
+
`Cannot resolve dependencies for ${name} — ${err.message}. Use --skip-install to link without dependency resolution.`
|
|
108
|
+
);
|
|
52
109
|
}
|
|
53
110
|
}
|
|
54
111
|
|
package/src/commands/login.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const fetch = require('node-fetch');
|
|
4
|
-
const {
|
|
4
|
+
const { saveSession, decodeExp } = require('../lib/auth');
|
|
5
5
|
const { DEFAULT_REGISTRY, throwIfError } = require('../lib/registry-client');
|
|
6
6
|
const { createInterface, ask, askPassword } = require('../util/prompt');
|
|
7
7
|
|
|
@@ -16,12 +16,7 @@ async function promptCredentials() {
|
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
async function login({ email, password } = {}, { registryUrl = DEFAULT_REGISTRY
|
|
20
|
-
if (token) {
|
|
21
|
-
saveToken(token);
|
|
22
|
-
console.log('Token saved.');
|
|
23
|
-
return;
|
|
24
|
-
}
|
|
19
|
+
async function login({ email, password } = {}, { registryUrl = DEFAULT_REGISTRY } = {}) {
|
|
25
20
|
if (!email || !password) {
|
|
26
21
|
({ email, password } = await promptCredentials());
|
|
27
22
|
}
|
|
@@ -34,8 +29,8 @@ async function login({ email, password } = {}, { registryUrl = DEFAULT_REGISTRY,
|
|
|
34
29
|
|
|
35
30
|
await throwIfError(res, 'Login failed');
|
|
36
31
|
|
|
37
|
-
const {
|
|
38
|
-
|
|
32
|
+
const { access_token, refresh_token } = await res.json();
|
|
33
|
+
saveSession({ registry: registryUrl, access: access_token, access_exp: decodeExp(access_token), refresh: refresh_token });
|
|
39
34
|
console.log('Logged in.');
|
|
40
35
|
}
|
|
41
36
|
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { readSession, clearSession, DEFAULT_REGISTRY } = require('../lib/auth');
|
|
4
|
+
const { postLogout } = require('../lib/registry-client');
|
|
5
|
+
|
|
6
|
+
async function logout() {
|
|
7
|
+
const s = readSession();
|
|
8
|
+
if (s && s.refresh) {
|
|
9
|
+
try {
|
|
10
|
+
await postLogout(s.refresh, s.registry || DEFAULT_REGISTRY);
|
|
11
|
+
} catch {
|
|
12
|
+
// Best-effort server revoke; always clear the local session below.
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
clearSession();
|
|
16
|
+
console.log('Logged out.');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
module.exports = { logout };
|
package/src/commands/preset.js
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const {
|
|
4
|
-
const { patchPreset, DEFAULT_REGISTRY } = require('../lib/registry-client');
|
|
3
|
+
const { requireAccess } = require('../lib/auth');
|
|
4
|
+
const { patchPreset, deletePreset, DEFAULT_REGISTRY } = require('../lib/registry-client');
|
|
5
5
|
|
|
6
6
|
async function presetSet(name, version, engineChannel, packs, { registryUrl = DEFAULT_REGISTRY } = {}) {
|
|
7
|
-
const token =
|
|
7
|
+
const token = await requireAccess();
|
|
8
8
|
await patchPreset(name, { version, engine_channel: engineChannel, packs }, token, registryUrl);
|
|
9
9
|
console.log(` Updated preset '${name}' to v${version}`);
|
|
10
10
|
console.log('Done.');
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
async function presetDelete(name, { registryUrl = DEFAULT_REGISTRY } = {}) {
|
|
14
|
+
const token = await requireAccess();
|
|
15
|
+
await deletePreset(name, token, registryUrl);
|
|
16
|
+
console.log(` Deleted preset '${name}'`);
|
|
17
|
+
console.log('Done.');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
module.exports = { presetSet, presetDelete };
|
package/src/commands/publish.js
CHANGED
|
@@ -9,14 +9,25 @@ const { readYaml } = require('../util/yaml');
|
|
|
9
9
|
const { validate } = require('./validate');
|
|
10
10
|
const { buildTarball, computeIntegrity } = require('../lib/tarball-builder');
|
|
11
11
|
const { PACK_MANIFEST } = require('../lib/manifest');
|
|
12
|
-
const {
|
|
13
|
-
const { DEFAULT_REGISTRY, throwIfError } = require('../lib/registry-client');
|
|
12
|
+
const { requireAccess } = require('../lib/auth');
|
|
13
|
+
const { DEFAULT_REGISTRY, throwIfError, exchangeOIDCForAccess } = require('../lib/registry-client');
|
|
14
|
+
const { detectCI, fetchGitHubIdToken, AUDIENCE } = require('../lib/oidc');
|
|
14
15
|
|
|
15
16
|
async function publish({ cwd = process.cwd(), registryUrl = DEFAULT_REGISTRY } = {}) {
|
|
16
17
|
validate({ cwd });
|
|
17
18
|
|
|
18
19
|
const manifest = readYaml(path.join(cwd, PACK_MANIFEST));
|
|
19
|
-
|
|
20
|
+
|
|
21
|
+
const scope = manifest.name.match(/^@([^/]+)\//)[1];
|
|
22
|
+
const ciMode = detectCI();
|
|
23
|
+
let token;
|
|
24
|
+
if (ciMode) {
|
|
25
|
+
console.log('Detected GitHub Actions OIDC environment — exchanging id-token...');
|
|
26
|
+
const idToken = await fetchGitHubIdToken(AUDIENCE);
|
|
27
|
+
token = await exchangeOIDCForAccess(scope, idToken, registryUrl);
|
|
28
|
+
} else {
|
|
29
|
+
token = await requireAccess();
|
|
30
|
+
}
|
|
20
31
|
|
|
21
32
|
const shortName = manifest.name.split('/')[1];
|
|
22
33
|
const tmpPath = path.join(
|
|
@@ -37,6 +48,10 @@ async function publish({ cwd = process.cwd(), registryUrl = DEFAULT_REGISTRY } =
|
|
|
37
48
|
});
|
|
38
49
|
form.append('metadata', JSON.stringify({ ...manifest, integrity }));
|
|
39
50
|
|
|
51
|
+
if (ciMode) {
|
|
52
|
+
form.append('tag', 'stable');
|
|
53
|
+
}
|
|
54
|
+
|
|
40
55
|
const res = await fetch(`${registryUrl}/v1/publish`, {
|
|
41
56
|
method: 'POST',
|
|
42
57
|
headers: {
|
package/src/commands/register.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const fetch = require('node-fetch');
|
|
4
|
-
const {
|
|
4
|
+
const { saveSession, decodeExp } = require('../lib/auth');
|
|
5
5
|
const { DEFAULT_REGISTRY, throwIfError } = require('../lib/registry-client');
|
|
6
6
|
const { createInterface, ask, askPassword } = require('../util/prompt');
|
|
7
7
|
|
|
@@ -30,8 +30,8 @@ async function register({ handle, email, password } = {}, { registryUrl = DEFAUL
|
|
|
30
30
|
|
|
31
31
|
await throwIfError(res, 'Registration failed');
|
|
32
32
|
|
|
33
|
-
const {
|
|
34
|
-
|
|
33
|
+
const { access_token, refresh_token } = await res.json();
|
|
34
|
+
saveSession({ registry: registryUrl, access: access_token, access_exp: decodeExp(access_token), refresh: refresh_token });
|
|
35
35
|
console.log(`Registered as ${handle}. Logged in.`);
|
|
36
36
|
}
|
|
37
37
|
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { requireAccess, readSession, DEFAULT_REGISTRY } = require('../lib/auth');
|
|
4
|
+
const {
|
|
5
|
+
createTrustedPublisher, listTrustedPublishers, deleteTrustedPublisher,
|
|
6
|
+
} = require('../lib/registry-client');
|
|
7
|
+
|
|
8
|
+
function registryUrl() {
|
|
9
|
+
const s = readSession();
|
|
10
|
+
return (s && s.registry) || DEFAULT_REGISTRY;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function trustAdd(scope, repo, { ref, environment } = {}) {
|
|
14
|
+
const token = await requireAccess();
|
|
15
|
+
const body = { scope, repo };
|
|
16
|
+
if (ref) { body.ref = ref; }
|
|
17
|
+
if (environment) { body.environment = environment; }
|
|
18
|
+
const row = await createTrustedPublisher(body, token, registryUrl());
|
|
19
|
+
console.log(`Trusted publisher #${row.id}: @${row.scope} <- ${row.repo}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function trustList(scope) {
|
|
23
|
+
const token = await requireAccess();
|
|
24
|
+
const rows = await listTrustedPublishers(scope, token, registryUrl());
|
|
25
|
+
if (!rows.length) {
|
|
26
|
+
console.log('No trusted publishers.');
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
for (const r of rows) {
|
|
30
|
+
const extra = [r.ref ? `ref=${r.ref}` : null, r.environment ? `env=${r.environment}` : null]
|
|
31
|
+
.filter(Boolean).join(' ');
|
|
32
|
+
console.log(`#${r.id} @${r.scope} <- ${r.repo}${extra ? ` (${extra})` : ''}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function trustRm(id) {
|
|
37
|
+
const token = await requireAccess();
|
|
38
|
+
await deleteTrustedPublisher(id, token, registryUrl());
|
|
39
|
+
console.log(`Removed trusted publisher #${id}.`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = { trustAdd, trustList, trustRm };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const fetch = require('node-fetch');
|
|
4
|
-
const {
|
|
4
|
+
const { requireAccess } = require('../lib/auth');
|
|
5
5
|
const { DEFAULT_REGISTRY } = require('../lib/registry-client');
|
|
6
6
|
const { createInterface, ask } = require('../util/prompt');
|
|
7
7
|
|
|
@@ -16,7 +16,7 @@ function parsePackageArg(arg) {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
async function unpublish(packageArg, { force = false, registryUrl = DEFAULT_REGISTRY } = {}) {
|
|
19
|
-
const token =
|
|
19
|
+
const token = await requireAccess();
|
|
20
20
|
const { pkg, version } = parsePackageArg(packageArg);
|
|
21
21
|
|
|
22
22
|
if (!/^@[a-z0-9-]+\/[a-z0-9-]+$/.test(pkg)) {
|
package/src/lib/auth.js
CHANGED
|
@@ -4,31 +4,81 @@ const fs = require('fs');
|
|
|
4
4
|
const os = require('os');
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const yaml = require('js-yaml');
|
|
7
|
+
const fetch = require('node-fetch');
|
|
7
8
|
|
|
8
9
|
const RC_PATH = path.join(os.homedir(), '.tapestryrc');
|
|
10
|
+
const DEFAULT_REGISTRY = process.env.TAPESTRY_REGISTRY || 'https://registry.tapestryengine.com';
|
|
11
|
+
const REFRESH_SKEW_SECONDS = 60; // refresh slightly before actual expiry
|
|
9
12
|
|
|
10
|
-
function
|
|
13
|
+
function readSession() {
|
|
11
14
|
if (!fs.existsSync(RC_PATH)) {
|
|
12
15
|
return null;
|
|
13
16
|
}
|
|
14
17
|
try {
|
|
15
|
-
|
|
16
|
-
return data?.token ?? null;
|
|
18
|
+
return yaml.load(fs.readFileSync(RC_PATH, 'utf8')) || null;
|
|
17
19
|
} catch {
|
|
18
20
|
return null;
|
|
19
21
|
}
|
|
20
22
|
}
|
|
21
23
|
|
|
22
|
-
function
|
|
23
|
-
fs.writeFileSync(
|
|
24
|
+
function saveSession({ registry, access, access_exp, refresh }) {
|
|
25
|
+
fs.writeFileSync(
|
|
26
|
+
RC_PATH,
|
|
27
|
+
yaml.dump({ registry, access, access_exp, refresh }, { lineWidth: -1 }),
|
|
28
|
+
{ mode: 0o600 }
|
|
29
|
+
);
|
|
24
30
|
}
|
|
25
31
|
|
|
26
|
-
function
|
|
27
|
-
|
|
28
|
-
|
|
32
|
+
function clearSession() {
|
|
33
|
+
if (fs.existsSync(RC_PATH)) {
|
|
34
|
+
fs.unlinkSync(RC_PATH);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function decodeExp(jwtString) {
|
|
39
|
+
try {
|
|
40
|
+
const payload = JSON.parse(Buffer.from(jwtString.split('.')[1], 'base64url').toString('utf8'));
|
|
41
|
+
return typeof payload.exp === 'number' ? payload.exp : null;
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function loadAccess() {
|
|
48
|
+
const s = readSession();
|
|
49
|
+
if (!s || !s.access || !s.refresh) {
|
|
50
|
+
return null; // absent, or legacy token-only rc
|
|
51
|
+
}
|
|
52
|
+
const now = Math.floor(Date.now() / 1000);
|
|
53
|
+
if (typeof s.access_exp === 'number' && now < s.access_exp - REFRESH_SKEW_SECONDS) {
|
|
54
|
+
return s.access;
|
|
55
|
+
}
|
|
56
|
+
// Access expired (or near it): silently refresh.
|
|
57
|
+
const registry = s.registry || DEFAULT_REGISTRY;
|
|
58
|
+
const res = await fetch(`${registry}/v1/auth/refresh`, {
|
|
59
|
+
method: 'POST',
|
|
60
|
+
headers: { 'Content-Type': 'application/json' },
|
|
61
|
+
body: JSON.stringify({ refresh_token: s.refresh }),
|
|
62
|
+
});
|
|
63
|
+
if (!res.ok) {
|
|
64
|
+
clearSession();
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
const { access_token, refresh_token } = await res.json();
|
|
68
|
+
saveSession({ registry, access: access_token, access_exp: decodeExp(access_token), refresh: refresh_token });
|
|
69
|
+
return access_token;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function requireAccess() {
|
|
73
|
+
const access = await loadAccess();
|
|
74
|
+
if (!access) {
|
|
29
75
|
throw new Error('Not logged in. Run: tapestry login');
|
|
30
76
|
}
|
|
31
|
-
return
|
|
77
|
+
return access;
|
|
32
78
|
}
|
|
33
79
|
|
|
34
|
-
module.exports = {
|
|
80
|
+
module.exports = {
|
|
81
|
+
RC_PATH, DEFAULT_REGISTRY,
|
|
82
|
+
readSession, saveSession, clearSession, decodeExp,
|
|
83
|
+
loadAccess, requireAccess,
|
|
84
|
+
};
|
package/src/lib/links.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const path = require('path');
|
|
4
4
|
const fs = require('fs');
|
|
5
|
+
const semver = require('semver');
|
|
5
6
|
const { readYaml, writeYaml } = require('../util/yaml');
|
|
6
7
|
const { PACK_MANIFEST } = require('./manifest');
|
|
7
8
|
|
|
@@ -113,8 +114,34 @@ function checkMissingDeps(cwd, manifest) {
|
|
|
113
114
|
return missing;
|
|
114
115
|
}
|
|
115
116
|
|
|
117
|
+
function partitionDeps(cwd, manifest) {
|
|
118
|
+
const deps = (manifest && manifest.dependencies) || {};
|
|
119
|
+
const needsInstall = {};
|
|
120
|
+
if (Object.keys(deps).length === 0) {
|
|
121
|
+
return { needsInstall };
|
|
122
|
+
}
|
|
123
|
+
const { links } = readLinks(cwd);
|
|
124
|
+
for (const [depName, range] of Object.entries(deps)) {
|
|
125
|
+
if (depName in links) {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const installPath = packLinkPath(cwd, depName);
|
|
129
|
+
if (fs.existsSync(installPath)) {
|
|
130
|
+
const manifestPath = path.join(installPath, PACK_MANIFEST);
|
|
131
|
+
if (fs.existsSync(manifestPath)) {
|
|
132
|
+
const installed = readYaml(manifestPath) || {};
|
|
133
|
+
if (installed.version && semver.satisfies(installed.version, range)) {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
needsInstall[depName] = range;
|
|
139
|
+
}
|
|
140
|
+
return { needsInstall };
|
|
141
|
+
}
|
|
142
|
+
|
|
116
143
|
module.exports = {
|
|
117
144
|
LINKS_FILE, readLinks, writeLinks, addLink, removeLink,
|
|
118
145
|
readPackManifest, packLinkPath, containerPackTarget, dockerLinkMounts,
|
|
119
|
-
materializeLinks, removeMaterializedLink, checkMissingDeps,
|
|
146
|
+
materializeLinks, removeMaterializedLink, checkMissingDeps, partitionDeps,
|
|
120
147
|
};
|
package/src/lib/oidc.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fetch = require('node-fetch');
|
|
4
|
+
|
|
5
|
+
const AUDIENCE = 'https://registry.tapestryengine.com';
|
|
6
|
+
|
|
7
|
+
function detectCI() {
|
|
8
|
+
return !!(process.env.ACTIONS_ID_TOKEN_REQUEST_URL && process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function fetchGitHubIdToken(audience = AUDIENCE) {
|
|
12
|
+
const base = process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
|
|
13
|
+
const reqToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
|
|
14
|
+
const url = `${base}&audience=${encodeURIComponent(audience)}`;
|
|
15
|
+
const res = await fetch(url, { headers: { Authorization: `Bearer ${reqToken}` } });
|
|
16
|
+
if (!res.ok) {
|
|
17
|
+
throw new Error(`failed to fetch GitHub OIDC id-token (HTTP ${res.status})`);
|
|
18
|
+
}
|
|
19
|
+
const { value } = await res.json();
|
|
20
|
+
if (!value) {
|
|
21
|
+
throw new Error('GitHub OIDC response had no token value');
|
|
22
|
+
}
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
module.exports = { detectCI, fetchGitHubIdToken, AUDIENCE };
|
|
@@ -116,7 +116,71 @@ async function patchPreset(name, payload, token, registryUrl = DEFAULT_REGISTRY)
|
|
|
116
116
|
return res.json();
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
async function deletePreset(name, token, registryUrl = DEFAULT_REGISTRY) {
|
|
120
|
+
const url = `${registryUrl.replace(/\/$/, '')}/v1/admin/presets/${name}`;
|
|
121
|
+
const res = await fetch(url, {
|
|
122
|
+
method: 'DELETE',
|
|
123
|
+
headers: {
|
|
124
|
+
Authorization: `Bearer ${token}`,
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
await throwIfError(res, `Failed to delete preset '${name}'`);
|
|
128
|
+
return res.json();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function postLogout(refreshToken, registryUrl = DEFAULT_REGISTRY) {
|
|
132
|
+
const res = await fetch(`${registryUrl.replace(/\/$/, '')}/v1/auth/logout`, {
|
|
133
|
+
method: 'POST',
|
|
134
|
+
headers: { 'Content-Type': 'application/json' },
|
|
135
|
+
body: JSON.stringify({ refresh_token: refreshToken }),
|
|
136
|
+
});
|
|
137
|
+
await throwIfError(res, 'Logout failed');
|
|
138
|
+
return res.json();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function createTrustedPublisher(body, token, registryUrl = DEFAULT_REGISTRY) {
|
|
142
|
+
const res = await fetch(`${registryUrl.replace(/\/$/, '')}/v1/trusted-publishers`, {
|
|
143
|
+
method: 'POST',
|
|
144
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
145
|
+
body: JSON.stringify(body),
|
|
146
|
+
});
|
|
147
|
+
await throwIfError(res, 'Failed to create trusted publisher');
|
|
148
|
+
return res.json();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function listTrustedPublishers(scope, token, registryUrl = DEFAULT_REGISTRY) {
|
|
152
|
+
const q = scope ? `?scope=${encodeURIComponent(scope)}` : '';
|
|
153
|
+
const res = await fetch(`${registryUrl.replace(/\/$/, '')}/v1/trusted-publishers${q}`, {
|
|
154
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
155
|
+
});
|
|
156
|
+
await throwIfError(res, 'Failed to list trusted publishers');
|
|
157
|
+
return res.json();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function deleteTrustedPublisher(id, token, registryUrl = DEFAULT_REGISTRY) {
|
|
161
|
+
const res = await fetch(`${registryUrl.replace(/\/$/, '')}/v1/trusted-publishers/${id}`, {
|
|
162
|
+
method: 'DELETE',
|
|
163
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
164
|
+
});
|
|
165
|
+
await throwIfError(res, 'Failed to delete trusted publisher');
|
|
166
|
+
return res.json();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function exchangeOIDCForAccess(scope, idToken, registryUrl = DEFAULT_REGISTRY) {
|
|
170
|
+
const res = await fetch(`${registryUrl.replace(/\/$/, '')}/v1/token`, {
|
|
171
|
+
method: 'POST',
|
|
172
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${idToken}` },
|
|
173
|
+
body: JSON.stringify({ scope }),
|
|
174
|
+
});
|
|
175
|
+
await throwIfError(res, 'OIDC token exchange failed');
|
|
176
|
+
const { access_token } = await res.json();
|
|
177
|
+
return access_token;
|
|
178
|
+
}
|
|
179
|
+
|
|
119
180
|
module.exports = {
|
|
120
181
|
fetchPackageMetadata, fetchTarball, throwIfError, DEFAULT_REGISTRY,
|
|
121
|
-
fetchPreset, fetchPresetList, patchDistTag, listDistTags, patchPreset,
|
|
182
|
+
fetchPreset, fetchPresetList, patchDistTag, listDistTags, patchPreset, deletePreset,
|
|
183
|
+
postLogout,
|
|
184
|
+
createTrustedPublisher, listTrustedPublishers, deleteTrustedPublisher,
|
|
185
|
+
exchangeOIDCForAccess,
|
|
122
186
|
};
|