@tapestry-mud/cli 0.6.0 → 0.8.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 +106 -6
- 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 +2 -2
- package/src/commands/link.js +2 -2
- package/src/commands/login.js +4 -9
- package/src/commands/logout.js +19 -0
- package/src/commands/preset.js +3 -3
- package/src/commands/publish.js +18 -3
- package/src/commands/register.js +3 -3
- package/src/commands/sync-area.js +156 -0
- package/src/commands/trust.js +42 -0
- package/src/commands/unpublish.js +2 -2
- package/src/commands/update.js +8 -1
- package/src/lib/auth.js +60 -10
- package/src/lib/engine-manager.js +16 -2
- package/src/lib/git.js +22 -0
- package/src/lib/oidc.js +26 -0
- package/src/lib/pack-manifest.js +50 -0
- package/src/lib/registry-client.js +52 -0
- package/src/scaffold/templates.js +2 -4
- package/src/schema/manifest.js +3 -7
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');
|
|
@@ -28,6 +29,8 @@ 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
31
|
const { presetSet, presetDelete } = require('../src/commands/preset');
|
|
32
|
+
const { trustAdd, trustList, trustRm } = require('../src/commands/trust');
|
|
33
|
+
const { syncArea } = require('../src/commands/sync-area');
|
|
31
34
|
|
|
32
35
|
const program = new Command();
|
|
33
36
|
|
|
@@ -53,11 +56,15 @@ program.configureHelp({
|
|
|
53
56
|
},
|
|
54
57
|
{
|
|
55
58
|
title: 'Account',
|
|
56
|
-
commands: ['register', 'login', 'change-password'],
|
|
59
|
+
commands: ['register', 'login', 'logout', 'change-password'],
|
|
57
60
|
},
|
|
58
61
|
{
|
|
59
62
|
title: 'Pack Authoring',
|
|
60
|
-
commands: ['create', 'validate', 'pack', 'publish', 'unpublish'],
|
|
63
|
+
commands: ['create', 'validate', 'pack', 'publish', 'unpublish', 'sync-area'],
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
title: 'Trusted Publishing',
|
|
67
|
+
commands: ['trust'],
|
|
61
68
|
},
|
|
62
69
|
{
|
|
63
70
|
title: 'Admin',
|
|
@@ -243,11 +250,22 @@ program
|
|
|
243
250
|
|
|
244
251
|
program
|
|
245
252
|
.command('login')
|
|
246
|
-
.description('Authenticate with the registry
|
|
247
|
-
.
|
|
248
|
-
|
|
253
|
+
.description('Authenticate with the registry (interactive password login)')
|
|
254
|
+
.action(async () => {
|
|
255
|
+
try {
|
|
256
|
+
await login();
|
|
257
|
+
} catch (e) {
|
|
258
|
+
console.error(`error: ${e.message}`);
|
|
259
|
+
process.exit(1);
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
program
|
|
264
|
+
.command('logout')
|
|
265
|
+
.description('Revoke your session and remove ~/.tapestryrc')
|
|
266
|
+
.action(async () => {
|
|
249
267
|
try {
|
|
250
|
-
await
|
|
268
|
+
await logout();
|
|
251
269
|
} catch (e) {
|
|
252
270
|
console.error(`error: ${e.message}`);
|
|
253
271
|
process.exit(1);
|
|
@@ -266,6 +284,47 @@ program
|
|
|
266
284
|
}
|
|
267
285
|
});
|
|
268
286
|
|
|
287
|
+
const trustCmd = program.command('trust').description('Manage trusted publishers (OIDC CI publishing)');
|
|
288
|
+
|
|
289
|
+
trustCmd
|
|
290
|
+
.command('add <scope> <repo>')
|
|
291
|
+
.description('Authorize a GitHub repo (owner/name) to publish to a scope via OIDC')
|
|
292
|
+
.option('--ref <ref>', 'Restrict to a git ref, e.g. refs/heads/master')
|
|
293
|
+
.option('--environment <env>', 'Restrict to a GitHub Actions environment')
|
|
294
|
+
.action(async (scope, repo, options) => {
|
|
295
|
+
try {
|
|
296
|
+
await trustAdd(scope, repo, { ref: options.ref, environment: options.environment });
|
|
297
|
+
} catch (e) {
|
|
298
|
+
console.error(`error: ${e.message}`);
|
|
299
|
+
process.exit(1);
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
trustCmd
|
|
304
|
+
.command('list')
|
|
305
|
+
.description('List trusted publishers you own (all, if admin)')
|
|
306
|
+
.option('--scope <scope>', 'Filter by scope')
|
|
307
|
+
.action(async (options) => {
|
|
308
|
+
try {
|
|
309
|
+
await trustList(options.scope);
|
|
310
|
+
} catch (e) {
|
|
311
|
+
console.error(`error: ${e.message}`);
|
|
312
|
+
process.exit(1);
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
trustCmd
|
|
317
|
+
.command('rm <id>')
|
|
318
|
+
.description('Remove a trusted publisher binding by id')
|
|
319
|
+
.action(async (id) => {
|
|
320
|
+
try {
|
|
321
|
+
await trustRm(id);
|
|
322
|
+
} catch (e) {
|
|
323
|
+
console.error(`error: ${e.message}`);
|
|
324
|
+
process.exit(1);
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
|
|
269
328
|
program
|
|
270
329
|
.command('validate')
|
|
271
330
|
.description('Validate pack.yaml in the current directory')
|
|
@@ -302,6 +361,47 @@ program
|
|
|
302
361
|
}
|
|
303
362
|
});
|
|
304
363
|
|
|
364
|
+
function runSyncArea(areaRef, opts) {
|
|
365
|
+
try {
|
|
366
|
+
syncArea(areaRef, {
|
|
367
|
+
cwd: process.cwd(),
|
|
368
|
+
gameRoot: opts.gameRoot,
|
|
369
|
+
pack: opts.pack,
|
|
370
|
+
force: opts.force,
|
|
371
|
+
keepSidecars: opts.keepSidecars,
|
|
372
|
+
bump: opts.major ? 'major' : opts.minor ? 'minor' : 'patch',
|
|
373
|
+
});
|
|
374
|
+
} catch (e) {
|
|
375
|
+
console.error(`error: ${e.message}`);
|
|
376
|
+
process.exit(1);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
program
|
|
381
|
+
.command('sync-area <areaRef>')
|
|
382
|
+
.description('Commit a game-root authored area back into its pack (areaRef = namespace:area-id)')
|
|
383
|
+
.option('--pack <dir>', 'Target pack directory (auto-detected from linked packs by default)')
|
|
384
|
+
.option('--game-root <path>', 'Game root containing data/ (default: current dir)')
|
|
385
|
+
.option('--keep-sidecars', 'Copy instead of move (leave the game-root side-cars in place)')
|
|
386
|
+
.option('--force', 'Overwrite pack files that diverge from the side-car')
|
|
387
|
+
.option('--minor', 'Bump the pack minor version (default: patch)')
|
|
388
|
+
.option('--major', 'Bump the pack major version (default: patch)')
|
|
389
|
+
.action(runSyncArea);
|
|
390
|
+
|
|
391
|
+
program
|
|
392
|
+
.command('export-area <areaRef>', { hidden: true })
|
|
393
|
+
.description('(deprecated) alias for sync-area')
|
|
394
|
+
.option('--pack <dir>', 'Target pack directory (auto-detected from linked packs by default)')
|
|
395
|
+
.option('--game-root <path>', 'Game root containing data/ (default: current dir)')
|
|
396
|
+
.option('--keep-sidecars', 'Copy instead of move (leave the game-root side-cars in place)')
|
|
397
|
+
.option('--force', 'Overwrite pack files that diverge from the side-car')
|
|
398
|
+
.option('--minor', 'Bump the pack minor version (default: patch)')
|
|
399
|
+
.option('--major', 'Bump the pack major version (default: patch)')
|
|
400
|
+
.action((areaRef, opts) => {
|
|
401
|
+
console.warn('warning: `export-area` is deprecated; use `sync-area`.');
|
|
402
|
+
runSyncArea(areaRef, opts);
|
|
403
|
+
});
|
|
404
|
+
|
|
305
405
|
program
|
|
306
406
|
.command('search <query>')
|
|
307
407
|
.description('Search the registry by keyword')
|
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
|
|
package/src/commands/link.js
CHANGED
|
@@ -10,7 +10,7 @@ const { addPackageToBoot, removePackageFromBoot } = require('../lib/boot');
|
|
|
10
10
|
const { resolve } = require('../lib/semver-resolver');
|
|
11
11
|
const { installResolved, packInstallPath } = require('./install');
|
|
12
12
|
const { readLock, writeLock } = require('../lib/lock-file');
|
|
13
|
-
const {
|
|
13
|
+
const { loadAccess } = require('../lib/auth');
|
|
14
14
|
const { DEFAULT_REGISTRY } = require('../lib/registry-client');
|
|
15
15
|
|
|
16
16
|
function requireProject(cwd) {
|
|
@@ -69,7 +69,7 @@ async function link(targetPath, { cwd = process.cwd(), noInstall = false, regist
|
|
|
69
69
|
|
|
70
70
|
let toRollback = [];
|
|
71
71
|
try {
|
|
72
|
-
const token =
|
|
72
|
+
const token = await loadAccess();
|
|
73
73
|
const resolved = await resolve(needsInstall, registryUrl, token);
|
|
74
74
|
|
|
75
75
|
// New installs: not on disk yet. Upgrade targets: in needsInstall AND on disk
|
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,17 +1,17 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const {
|
|
3
|
+
const { requireAccess } = require('../lib/auth');
|
|
4
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 =
|
|
14
|
+
const token = await requireAccess();
|
|
15
15
|
await deletePreset(name, token, registryUrl);
|
|
16
16
|
console.log(` Deleted preset '${name}'`);
|
|
17
17
|
console.log('Done.');
|
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,156 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { readYaml, writeYaml } = require('../util/yaml');
|
|
6
|
+
const { readLinks } = require('../lib/links');
|
|
7
|
+
const { ensureContentGlobs, bumpVersion } = require('../lib/pack-manifest');
|
|
8
|
+
const { isRepo, commitAll } = require('../lib/git');
|
|
9
|
+
|
|
10
|
+
// "@legends/forgotten" -> "legends-forgotten" (mirrors engine PackLoader.PackNamespace)
|
|
11
|
+
function packNamespace(name) {
|
|
12
|
+
if (name.indexOf('/') === -1) {
|
|
13
|
+
return name;
|
|
14
|
+
}
|
|
15
|
+
return name.replace(/^@/, '').split('/').join('-');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function detectPackDir(cwd, namespace, explicitPack) {
|
|
19
|
+
if (explicitPack) {
|
|
20
|
+
return path.isAbsolute(explicitPack) ? explicitPack : path.join(cwd, explicitPack);
|
|
21
|
+
}
|
|
22
|
+
const { links } = readLinks(cwd);
|
|
23
|
+
const matches = [];
|
|
24
|
+
for (const [name, dir] of Object.entries(links)) {
|
|
25
|
+
let derivedNs = namespace;
|
|
26
|
+
try {
|
|
27
|
+
const manifest = readYaml(path.join(dir, 'pack.yaml')) || {};
|
|
28
|
+
derivedNs = packNamespace(manifest.name || name);
|
|
29
|
+
} catch (e) {
|
|
30
|
+
derivedNs = packNamespace(name);
|
|
31
|
+
}
|
|
32
|
+
if (derivedNs === namespace) {
|
|
33
|
+
matches.push(dir);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (matches.length === 1) {
|
|
37
|
+
return matches[0];
|
|
38
|
+
}
|
|
39
|
+
if (matches.length === 0) {
|
|
40
|
+
throw new Error(`Could not auto-detect a pack for namespace '${namespace}'. Pass --pack <dir>.`);
|
|
41
|
+
}
|
|
42
|
+
throw new Error(`Multiple linked packs match namespace '${namespace}'. Pass --pack <dir>.`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function syncArea(areaRef, options) {
|
|
46
|
+
options = options || {};
|
|
47
|
+
const cwd = options.cwd || process.cwd();
|
|
48
|
+
const gameRoot = options.gameRoot || cwd;
|
|
49
|
+
const force = !!options.force;
|
|
50
|
+
const bumpLevel = options.bump || 'patch';
|
|
51
|
+
const keepSidecars = !!options.keepSidecars;
|
|
52
|
+
|
|
53
|
+
const colon = areaRef.indexOf(':');
|
|
54
|
+
if (colon < 1) {
|
|
55
|
+
throw new Error('Usage: sync-area <namespace:area-id> [--pack <dir>]');
|
|
56
|
+
}
|
|
57
|
+
const namespace = areaRef.slice(0, colon);
|
|
58
|
+
const area = areaRef.slice(colon + 1);
|
|
59
|
+
|
|
60
|
+
const sideCarRooms = path.join(gameRoot, 'data', 'areas', area, 'rooms');
|
|
61
|
+
if (!fs.existsSync(sideCarRooms)) {
|
|
62
|
+
throw new Error(`No authored rooms found for area '${area}' at ${sideCarRooms}`);
|
|
63
|
+
}
|
|
64
|
+
const files = fs.readdirSync(sideCarRooms).filter((f) => f.endsWith('.yaml'));
|
|
65
|
+
if (files.length === 0) {
|
|
66
|
+
throw new Error(`No authored rooms found for area '${area}'`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const packDir = detectPackDir(cwd, namespace, options.pack);
|
|
70
|
+
|
|
71
|
+
const destManifestPath = path.join(packDir, 'pack.yaml');
|
|
72
|
+
if (!fs.existsSync(destManifestPath)) {
|
|
73
|
+
throw new Error(`No pack.yaml found in ${packDir}. sync-area targets an existing pack; pass --pack <dir> pointing at one.`);
|
|
74
|
+
}
|
|
75
|
+
const destManifest = readYaml(destManifestPath) || {};
|
|
76
|
+
if (!destManifest.name) {
|
|
77
|
+
throw new Error(`pack.yaml in ${packDir} has no 'name' field.`);
|
|
78
|
+
}
|
|
79
|
+
const destNamespace = packNamespace(destManifest.name);
|
|
80
|
+
if (destNamespace !== namespace) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`Pack namespace '${destNamespace}' does not match area namespace '${namespace}'. ` +
|
|
83
|
+
'sync-area only commits an area back into its own pack; use link (or a future migrate) for cross-pack moves.'
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const targetRooms = path.join(packDir, 'areas', area, 'rooms');
|
|
88
|
+
fs.mkdirSync(targetRooms, { recursive: true });
|
|
89
|
+
|
|
90
|
+
const sideCarAreaYaml = path.join(gameRoot, 'data', 'areas', area, 'area.yaml');
|
|
91
|
+
const targetAreaYaml = path.join(packDir, 'areas', area, 'area.yaml');
|
|
92
|
+
if (fs.existsSync(sideCarAreaYaml)) {
|
|
93
|
+
// Authored area.yaml already carries the full `area:` envelope (Spec A) — copy it home.
|
|
94
|
+
writeYaml(targetAreaYaml, readYaml(sideCarAreaYaml));
|
|
95
|
+
} else if (!fs.existsSync(targetAreaYaml)) {
|
|
96
|
+
// No authored or pack area.yaml — synthesize a minimal valid envelope so it strict-boots.
|
|
97
|
+
writeYaml(targetAreaYaml, {
|
|
98
|
+
area: { id: area, name: area, level_range: [1, 99], reset_interval: 300 },
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let written = 0;
|
|
103
|
+
for (const file of files) {
|
|
104
|
+
const src = path.join(sideCarRooms, file);
|
|
105
|
+
const dest = path.join(targetRooms, file);
|
|
106
|
+
const incoming = readYaml(src);
|
|
107
|
+
if (fs.existsSync(dest) && !force) {
|
|
108
|
+
const existing = readYaml(dest);
|
|
109
|
+
if (JSON.stringify(existing) !== JSON.stringify(incoming)) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
`Pack file ${dest} diverges from the side-car. Review the diff and re-run with --force to overwrite.`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
writeYaml(dest, incoming);
|
|
115
|
+
written++;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
ensureContentGlobs(packDir);
|
|
119
|
+
|
|
120
|
+
const { old, new: next } = bumpVersion(packDir, bumpLevel);
|
|
121
|
+
let committed = false;
|
|
122
|
+
if (isRepo(packDir)) {
|
|
123
|
+
commitAll(packDir, `content(${area}): sync authored edits, bump ${old} -> ${next}`);
|
|
124
|
+
committed = true;
|
|
125
|
+
} else {
|
|
126
|
+
console.warn(`warn: ${packDir} is not a git repo; bumped to ${next} but did not commit.`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Move = delete the game-root side-cars now that the content is durably in the pack.
|
|
130
|
+
// Runs after the write/commit above. In the non-git fallback the content is written
|
|
131
|
+
// (not committed); deletion is still correct because the pack dir holds the files.
|
|
132
|
+
if (!keepSidecars) {
|
|
133
|
+
const areaSideCarDir = path.join(gameRoot, 'data', 'areas', area);
|
|
134
|
+
for (const file of files) {
|
|
135
|
+
fs.rmSync(path.join(sideCarRooms, file));
|
|
136
|
+
}
|
|
137
|
+
if (fs.existsSync(sideCarRooms) && fs.readdirSync(sideCarRooms).length === 0) {
|
|
138
|
+
fs.rmdirSync(sideCarRooms);
|
|
139
|
+
}
|
|
140
|
+
const sideCarAreaYamlToDelete = path.join(areaSideCarDir, 'area.yaml');
|
|
141
|
+
if (fs.existsSync(sideCarAreaYamlToDelete)) {
|
|
142
|
+
fs.rmSync(sideCarAreaYamlToDelete);
|
|
143
|
+
}
|
|
144
|
+
if (fs.existsSync(areaSideCarDir) && fs.readdirSync(areaSideCarDir).length === 0) {
|
|
145
|
+
fs.rmdirSync(areaSideCarDir);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
console.log(`Synced ${written} room(s) for area '${area}' into ${packDir} (v${old} -> v${next}).`);
|
|
150
|
+
if (committed) {
|
|
151
|
+
console.log('To publish + deploy, push the pack repo:');
|
|
152
|
+
console.log(` cd ${packDir} && git push`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
module.exports = { syncArea, exportArea: syncArea, packNamespace, detectPackDir };
|
|
@@ -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/commands/update.js
CHANGED
|
@@ -53,7 +53,14 @@ async function update(packageArg, { cwd = process.cwd(), registryUrl = DEFAULT_R
|
|
|
53
53
|
|
|
54
54
|
const destDir = packInstallPath(cwd, packageName);
|
|
55
55
|
if (fs.existsSync(destDir)) {
|
|
56
|
-
|
|
56
|
+
try {
|
|
57
|
+
fs.rmSync(destDir, { recursive: true });
|
|
58
|
+
} catch (e) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`could not replace ${packageName} at ${destDir}: ${e.message}. ` +
|
|
61
|
+
`The user running tapestry may not own the packs directory.`
|
|
62
|
+
);
|
|
63
|
+
}
|
|
57
64
|
}
|
|
58
65
|
|
|
59
66
|
const safeId = packageName.replace('@', '').replace('/', '-');
|
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
|
+
};
|
|
@@ -66,7 +66,7 @@ function dockerEnsureImage(image, version) {
|
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
function dockerStart(projectName, image, version, packsDir, serverYamlPath, dataDir, network, linkMounts = []) {
|
|
69
|
+
function dockerStart(projectName, image, version, packsDir, serverYamlPath, dataDir, network, linkMounts = [], envFile = null) {
|
|
70
70
|
const containerName = `tapestry-${projectName}`;
|
|
71
71
|
dockerEnsureImage(image, version);
|
|
72
72
|
spawnSync('docker', ['rm', '-f', containerName], { stdio: 'ignore' });
|
|
@@ -80,6 +80,9 @@ function dockerStart(projectName, image, version, packsDir, serverYamlPath, data
|
|
|
80
80
|
'-v', `${dataDir}:/app/data`,
|
|
81
81
|
...linkMounts,
|
|
82
82
|
];
|
|
83
|
+
if (envFile) {
|
|
84
|
+
args.push('--env-file', envFile);
|
|
85
|
+
}
|
|
83
86
|
if (network) {
|
|
84
87
|
args.push('--network', network);
|
|
85
88
|
}
|
|
@@ -268,6 +271,7 @@ function readEngineConfig(cwd) {
|
|
|
268
271
|
mode: engine.mode,
|
|
269
272
|
image: engine.image || DEFAULT_IMAGE,
|
|
270
273
|
network: engine.network || null,
|
|
274
|
+
envFile: engine.env_file || null,
|
|
271
275
|
installDir: path.join(cwd, '.tapestry-engine'),
|
|
272
276
|
projectName: (manifest.name || 'tapestry').toLowerCase().replace(/[^a-z0-9-]+/g, '-'),
|
|
273
277
|
};
|
|
@@ -324,7 +328,17 @@ async function startEngine(cwd) {
|
|
|
324
328
|
fs.mkdirSync(dataDir, { recursive: true });
|
|
325
329
|
if (config.mode === 'docker') {
|
|
326
330
|
const tag = await resolveDockerTag(config);
|
|
327
|
-
|
|
331
|
+
let envFile = null;
|
|
332
|
+
if (config.envFile) {
|
|
333
|
+
envFile = path.resolve(cwd, config.envFile);
|
|
334
|
+
if (!fs.existsSync(envFile)) {
|
|
335
|
+
throw new Error(
|
|
336
|
+
`engine.env_file '${config.envFile}' not found at ${envFile}. ` +
|
|
337
|
+
'The file must exist on the host running tapestry start.'
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
dockerStart(config.projectName, config.image, tag, packsDir, serverYamlPath, dataDir, config.network, dockerLinkMounts(cwd), envFile);
|
|
328
342
|
} else if (config.mode === 'binary') {
|
|
329
343
|
materializeLinks(cwd);
|
|
330
344
|
binaryStart(config.version, config.installDir, packsDir, serverYamlPath, cwd);
|
package/src/lib/git.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { spawnSync } = require('child_process');
|
|
4
|
+
|
|
5
|
+
function isRepo(dir) {
|
|
6
|
+
const res = spawnSync('git', ['-C', dir, 'rev-parse', '--is-inside-work-tree'], { stdio: 'ignore' });
|
|
7
|
+
return res.status === 0;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Stage everything in dir and commit. Throws on failure.
|
|
11
|
+
function commitAll(dir, message) {
|
|
12
|
+
const add = spawnSync('git', ['-C', dir, 'add', '-A'], { stdio: 'ignore' });
|
|
13
|
+
if (add.status !== 0) {
|
|
14
|
+
throw new Error(`git add failed in ${dir}`);
|
|
15
|
+
}
|
|
16
|
+
const commit = spawnSync('git', ['-C', dir, 'commit', '-m', message], { stdio: 'ignore' });
|
|
17
|
+
if (commit.status !== 0) {
|
|
18
|
+
throw new Error(`git commit failed in ${dir}`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
module.exports = { isRepo, commitAll };
|
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 };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const semver = require('semver');
|
|
5
|
+
const { readYaml, writeYaml } = require('../util/yaml');
|
|
6
|
+
|
|
7
|
+
const CONTENT_GLOBS = {
|
|
8
|
+
area_definitions: 'areas/**/area.yaml',
|
|
9
|
+
rooms: 'areas/**/rooms/*.yaml',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// Additively ensure the pack manifest declares the given content globs.
|
|
13
|
+
// Returns the list of keys that were added (empty if no change).
|
|
14
|
+
function ensureContentGlobs(packDir, globs = CONTENT_GLOBS) {
|
|
15
|
+
const manifestPath = path.join(packDir, 'pack.yaml');
|
|
16
|
+
const manifest = readYaml(manifestPath) || {};
|
|
17
|
+
if (!manifest.content || typeof manifest.content !== 'object') {
|
|
18
|
+
manifest.content = {};
|
|
19
|
+
}
|
|
20
|
+
const added = [];
|
|
21
|
+
for (const [key, value] of Object.entries(globs)) {
|
|
22
|
+
if (!(key in manifest.content)) {
|
|
23
|
+
manifest.content[key] = value;
|
|
24
|
+
added.push(key);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (added.length > 0) {
|
|
28
|
+
writeYaml(manifestPath, manifest);
|
|
29
|
+
}
|
|
30
|
+
return added;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Bump the pack version (patch|minor|major). Returns { old, new }.
|
|
34
|
+
function bumpVersion(packDir, level = 'patch') {
|
|
35
|
+
const manifestPath = path.join(packDir, 'pack.yaml');
|
|
36
|
+
const manifest = readYaml(manifestPath) || {};
|
|
37
|
+
const old = manifest.version;
|
|
38
|
+
if (!old || !semver.valid(old)) {
|
|
39
|
+
throw new Error(`pack.yaml has no valid semver version (found: ${old}). Cannot bump.`);
|
|
40
|
+
}
|
|
41
|
+
const next = semver.inc(old, level);
|
|
42
|
+
if (!next) {
|
|
43
|
+
throw new Error(`Invalid bump level '${level}'. Expected patch, minor, or major.`);
|
|
44
|
+
}
|
|
45
|
+
manifest.version = next;
|
|
46
|
+
writeYaml(manifestPath, manifest);
|
|
47
|
+
return { old, new: next };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { ensureContentGlobs, bumpVersion, CONTENT_GLOBS };
|
|
@@ -128,7 +128,59 @@ async function deletePreset(name, token, registryUrl = DEFAULT_REGISTRY) {
|
|
|
128
128
|
return res.json();
|
|
129
129
|
}
|
|
130
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
|
+
|
|
131
180
|
module.exports = {
|
|
132
181
|
fetchPackageMetadata, fetchTarball, throwIfError, DEFAULT_REGISTRY,
|
|
133
182
|
fetchPreset, fetchPresetList, patchDistTag, listDistTags, patchPreset, deletePreset,
|
|
183
|
+
postLogout,
|
|
184
|
+
createTrustedPublisher, listTrustedPublishers, deleteTrustedPublisher,
|
|
185
|
+
exchangeOIDCForAccess,
|
|
134
186
|
};
|
|
@@ -7,9 +7,7 @@ version: "0.1.0"
|
|
|
7
7
|
type: "module" # core | module | world
|
|
8
8
|
display_name: "TODO: Human-readable name"
|
|
9
9
|
description: "TODO: One-line description for registry search"
|
|
10
|
-
author:
|
|
11
|
-
name: "TODO: Your Name"
|
|
12
|
-
handle: "TODO: your-registry-handle"
|
|
10
|
+
author: "TODO: Your Name"
|
|
13
11
|
license: "MIT"
|
|
14
12
|
|
|
15
13
|
# Packs default to public. Add \`private: true\` to restrict access to your
|
|
@@ -39,7 +37,7 @@ tags: "tags.yml"
|
|
|
39
37
|
|
|
40
38
|
# Glob patterns -- the engine uses these to find your content
|
|
41
39
|
content:
|
|
42
|
-
|
|
40
|
+
area_definitions: "areas/**/area.yaml"
|
|
43
41
|
rooms: "areas/**/rooms/*.yaml"
|
|
44
42
|
items: "areas/**/items/*.yaml"
|
|
45
43
|
mobs: "areas/**/mobs/*.yaml"
|
package/src/schema/manifest.js
CHANGED
|
@@ -10,13 +10,7 @@ const PackageManifestSchema = z.object({
|
|
|
10
10
|
type: z.enum(['core', 'module', 'world']),
|
|
11
11
|
display_name: z.string().min(1),
|
|
12
12
|
description: z.string().min(1),
|
|
13
|
-
author: z.
|
|
14
|
-
z.string().min(1),
|
|
15
|
-
z.object({
|
|
16
|
-
name: z.string().min(1),
|
|
17
|
-
handle: z.string().min(1),
|
|
18
|
-
}),
|
|
19
|
-
]),
|
|
13
|
+
author: z.string().min(1),
|
|
20
14
|
license: z.string().min(1),
|
|
21
15
|
engine: z.string().min(1),
|
|
22
16
|
validation: z.enum(['strict', 'lenient']),
|
|
@@ -52,6 +46,8 @@ const ProjectManifestSchema = z.object({
|
|
|
52
46
|
version: z.string().min(1),
|
|
53
47
|
mode: z.enum(['docker', 'binary', 'source']),
|
|
54
48
|
image: z.string().optional(),
|
|
49
|
+
network: z.string().optional(),
|
|
50
|
+
env_file: z.string().optional(),
|
|
55
51
|
}),
|
|
56
52
|
]),
|
|
57
53
|
dependencies: z.record(z.string()).optional(),
|