@tapestry-mud/cli 0.7.0 → 0.9.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/bin/tapestry.js +79 -1
- package/package.json +1 -1
- package/src/commands/harvest.js +36 -0
- package/src/commands/status.js +22 -0
- package/src/commands/sync-area.js +51 -0
- package/src/commands/update.js +8 -1
- package/src/lib/engine-manager.js +16 -2
- package/src/lib/file-sink.js +62 -0
- package/src/lib/git.js +22 -0
- package/src/lib/pack-manifest.js +87 -0
- package/src/lib/pack-resolve.js +67 -0
- package/src/lib/render-core.js +103 -0
- package/src/lib/tarball-builder.js +1 -1
- package/src/lib/world-state.js +79 -0
- package/src/scaffold/templates.js +2 -4
- package/src/schema/manifest.js +3 -7
package/bin/tapestry.js
CHANGED
|
@@ -30,6 +30,9 @@ const { unpublish } = require('../src/commands/unpublish');
|
|
|
30
30
|
const { distTagSet, distTagList } = require('../src/commands/dist-tag');
|
|
31
31
|
const { presetSet, presetDelete } = require('../src/commands/preset');
|
|
32
32
|
const { trustAdd, trustList, trustRm } = require('../src/commands/trust');
|
|
33
|
+
const { syncArea } = require('../src/commands/sync-area');
|
|
34
|
+
const { harvest } = require('../src/commands/harvest');
|
|
35
|
+
const { status } = require('../src/commands/status');
|
|
33
36
|
|
|
34
37
|
const program = new Command();
|
|
35
38
|
|
|
@@ -59,7 +62,7 @@ program.configureHelp({
|
|
|
59
62
|
},
|
|
60
63
|
{
|
|
61
64
|
title: 'Pack Authoring',
|
|
62
|
-
commands: ['create', 'validate', 'pack', 'publish', 'unpublish'],
|
|
65
|
+
commands: ['create', 'validate', 'pack', 'publish', 'unpublish', 'harvest', 'status'],
|
|
63
66
|
},
|
|
64
67
|
{
|
|
65
68
|
title: 'Trusted Publishing',
|
|
@@ -360,6 +363,81 @@ program
|
|
|
360
363
|
}
|
|
361
364
|
});
|
|
362
365
|
|
|
366
|
+
program
|
|
367
|
+
.command('status')
|
|
368
|
+
.description('Show world state per area (Clean / Edited / Fork / WIP)')
|
|
369
|
+
.option('--game-root <path>', 'Game root containing data/ (default: current dir)')
|
|
370
|
+
.action((opts) => {
|
|
371
|
+
try {
|
|
372
|
+
status({ cwd: process.cwd(), gameRoot: opts.gameRoot });
|
|
373
|
+
} catch (e) {
|
|
374
|
+
console.error(`error: ${e.message}`);
|
|
375
|
+
process.exit(1);
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
async function runHarvest(areaRef, opts) {
|
|
380
|
+
try {
|
|
381
|
+
await harvest(areaRef, {
|
|
382
|
+
cwd: process.cwd(),
|
|
383
|
+
gameRoot: opts.gameRoot,
|
|
384
|
+
pack: opts.pack,
|
|
385
|
+
sink: opts.sink,
|
|
386
|
+
out: opts.out,
|
|
387
|
+
name: opts.name,
|
|
388
|
+
force: opts.force,
|
|
389
|
+
keepSidecars: opts.keepSidecars,
|
|
390
|
+
bump: opts.major ? 'major' : opts.minor ? 'minor' : 'patch',
|
|
391
|
+
});
|
|
392
|
+
} catch (e) {
|
|
393
|
+
console.error(`error: ${e.message}`);
|
|
394
|
+
process.exit(1);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
program
|
|
399
|
+
.command('harvest <areaRef>')
|
|
400
|
+
.description('Harvest an authored area into a portable pack (areaRef = namespace:area-id)')
|
|
401
|
+
.option('--sink <sink>', 'Output sink: file | git (auto-detected by default)')
|
|
402
|
+
.option('--out <path>', '(file sink) where the .tgz lands')
|
|
403
|
+
.option('--name <name>', '(file sink) override the synthesized pack name (@scope/pack)')
|
|
404
|
+
.option('--pack <dir>', 'Target pack directory (auto-detected from linked packs by default)')
|
|
405
|
+
.option('--game-root <path>', 'Game root containing data/ (default: current dir)')
|
|
406
|
+
.option('--keep-sidecars', 'Copy instead of move (leave the game-root side-cars in place)')
|
|
407
|
+
.option('--force', 'Overwrite pack files that diverge from the side-car')
|
|
408
|
+
.option('--minor', 'Bump the pack minor version (git sink only; default: patch)')
|
|
409
|
+
.option('--major', 'Bump the pack major version (git sink only; default: patch)')
|
|
410
|
+
.action(runHarvest);
|
|
411
|
+
|
|
412
|
+
// Deprecated: sync-area is now harvest --sink git.
|
|
413
|
+
program
|
|
414
|
+
.command('sync-area <areaRef>')
|
|
415
|
+
.description('(deprecated) alias for harvest --sink git')
|
|
416
|
+
.option('--pack <dir>', 'Target pack directory (auto-detected from linked packs by default)')
|
|
417
|
+
.option('--game-root <path>', 'Game root containing data/ (default: current dir)')
|
|
418
|
+
.option('--keep-sidecars', 'Copy instead of move (leave the game-root side-cars in place)')
|
|
419
|
+
.option('--force', 'Overwrite pack files that diverge from the side-car')
|
|
420
|
+
.option('--minor', 'Bump the pack minor version (default: patch)')
|
|
421
|
+
.option('--major', 'Bump the pack major version (default: patch)')
|
|
422
|
+
.action((areaRef, opts) => {
|
|
423
|
+
console.warn('warning: `sync-area` is deprecated; use `harvest <area>` (auto-detects the git sink for an owned repo).');
|
|
424
|
+
runHarvest(areaRef, Object.assign({ sink: 'git' }, opts));
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
program
|
|
428
|
+
.command('export-area <areaRef>', { hidden: true })
|
|
429
|
+
.description('(deprecated) alias for harvest --sink git')
|
|
430
|
+
.option('--pack <dir>', 'Target pack directory (auto-detected from linked packs by default)')
|
|
431
|
+
.option('--game-root <path>', 'Game root containing data/ (default: current dir)')
|
|
432
|
+
.option('--keep-sidecars', 'Copy instead of move (leave the game-root side-cars in place)')
|
|
433
|
+
.option('--force', 'Overwrite pack files that diverge from the side-car')
|
|
434
|
+
.option('--minor', 'Bump the pack minor version (default: patch)')
|
|
435
|
+
.option('--major', 'Bump the pack major version (default: patch)')
|
|
436
|
+
.action((areaRef, opts) => {
|
|
437
|
+
console.warn('warning: `export-area` is deprecated; use `harvest <area> --sink git`.');
|
|
438
|
+
runHarvest(areaRef, Object.assign({ sink: 'git' }, opts));
|
|
439
|
+
});
|
|
440
|
+
|
|
363
441
|
program
|
|
364
442
|
.command('search <query>')
|
|
365
443
|
.description('Search the registry by keyword')
|
package/package.json
CHANGED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { parseAreaRef, resolvePackDirOrNull } = require('../lib/pack-resolve');
|
|
4
|
+
const { isRepo } = require('../lib/git');
|
|
5
|
+
const { syncArea } = require('./sync-area');
|
|
6
|
+
const { fileSink } = require('../lib/file-sink');
|
|
7
|
+
|
|
8
|
+
// Umbrella harvest verb. Auto-detects the sink (owned linked pack that is a git repo -> git;
|
|
9
|
+
// else file) unless --sink is explicit. The render core is shared by every sink.
|
|
10
|
+
async function harvest(areaRef, options = {}) {
|
|
11
|
+
const cwd = options.cwd || process.cwd();
|
|
12
|
+
const gameRoot = options.gameRoot || cwd;
|
|
13
|
+
const { namespace, area } = parseAreaRef(areaRef);
|
|
14
|
+
|
|
15
|
+
let sink = options.sink;
|
|
16
|
+
if (!sink) {
|
|
17
|
+
const packDir = resolvePackDirOrNull(cwd, namespace, options.pack);
|
|
18
|
+
sink = (packDir && isRepo(packDir)) ? 'git' : 'file';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (sink === 'git') {
|
|
22
|
+
// The git sink IS sync-area: render into the real repo, bump, commit, print push, move.
|
|
23
|
+
return syncArea(areaRef, options);
|
|
24
|
+
}
|
|
25
|
+
if (sink === 'file') {
|
|
26
|
+
// The file sink snapshots at the current version -- it ignores --minor/--major (git-sink only).
|
|
27
|
+
return fileSink(areaRef, {
|
|
28
|
+
cwd, gameRoot, namespace, area,
|
|
29
|
+
force: options.force, keepSidecars: options.keepSidecars,
|
|
30
|
+
out: options.out, name: options.name, pack: options.pack,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
throw new Error(`Unknown sink '${sink}'. Use 'file' or 'git'.`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
module.exports = { harvest };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { computeAreaStates } = require('../lib/world-state');
|
|
4
|
+
|
|
5
|
+
function status({ cwd = process.cwd(), gameRoot } = {}) {
|
|
6
|
+
const root = gameRoot || cwd;
|
|
7
|
+
const rows = computeAreaStates(cwd, root);
|
|
8
|
+
if (!rows.length) {
|
|
9
|
+
console.log('No authored areas under data/areas.');
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
console.log('World state (area-level):');
|
|
13
|
+
for (const r of rows) {
|
|
14
|
+
const wipTag = r.wip ? ' [WIP]' : '';
|
|
15
|
+
console.log(` ${r.state.padEnd(7)} ${r.area} (${r.namespace || '?'}) rooms:${r.roomCount}${wipTag}`);
|
|
16
|
+
}
|
|
17
|
+
console.log('');
|
|
18
|
+
console.log('Clean = shipped; Edited = ready to harvest; Fork = owned by another pack; WIP = hidden from players.');
|
|
19
|
+
console.log('Strict boot remains the authority; this is a lighter on-host view.');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
module.exports = { status };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { readYaml } = require('../util/yaml');
|
|
6
|
+
const { bumpVersion } = require('../lib/pack-manifest');
|
|
7
|
+
const { isRepo, commitAll } = require('../lib/git');
|
|
8
|
+
const { packNamespace, detectPackDir, parseAreaRef } = require('../lib/pack-resolve');
|
|
9
|
+
const { renderArea, removeSideCars, assertNamespaceMatch } = require('../lib/render-core');
|
|
10
|
+
|
|
11
|
+
function syncArea(areaRef, options) {
|
|
12
|
+
options = options || {};
|
|
13
|
+
const cwd = options.cwd || process.cwd();
|
|
14
|
+
const gameRoot = options.gameRoot || cwd;
|
|
15
|
+
const force = !!options.force;
|
|
16
|
+
const bumpLevel = options.bump || 'patch';
|
|
17
|
+
const keepSidecars = !!options.keepSidecars;
|
|
18
|
+
|
|
19
|
+
const { namespace, area } = parseAreaRef(areaRef);
|
|
20
|
+
|
|
21
|
+
const packDir = detectPackDir(cwd, namespace, options.pack);
|
|
22
|
+
const destManifestPath = path.join(packDir, 'pack.yaml');
|
|
23
|
+
if (!fs.existsSync(destManifestPath)) {
|
|
24
|
+
throw new Error(`No pack.yaml found in ${packDir}. harvest --sink git targets an existing pack; pass --pack <dir> pointing at one.`);
|
|
25
|
+
}
|
|
26
|
+
const destManifest = readYaml(destManifestPath) || {};
|
|
27
|
+
assertNamespaceMatch(destManifest, namespace, packDir);
|
|
28
|
+
|
|
29
|
+
const { written, files } = renderArea(packDir, { gameRoot, area, force });
|
|
30
|
+
|
|
31
|
+
const { old, new: next } = bumpVersion(packDir, bumpLevel);
|
|
32
|
+
let committed = false;
|
|
33
|
+
if (isRepo(packDir)) {
|
|
34
|
+
commitAll(packDir, `content(${area}): sync authored edits, bump ${old} -> ${next}`);
|
|
35
|
+
committed = true;
|
|
36
|
+
} else {
|
|
37
|
+
console.warn(`warn: ${packDir} is not a git repo; bumped to ${next} but did not commit.`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!keepSidecars) {
|
|
41
|
+
removeSideCars(gameRoot, area, files);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
console.log(`Synced ${written} room(s) for area '${area}' into ${packDir} (v${old} -> v${next}).`);
|
|
45
|
+
if (committed) {
|
|
46
|
+
console.log('To publish + deploy, push the pack repo:');
|
|
47
|
+
console.log(` cd ${packDir} && git push`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { syncArea, exportArea: syncArea, packNamespace, detectPackDir };
|
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('/', '-');
|
|
@@ -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);
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { readYaml, writeYaml } = require('../util/yaml');
|
|
7
|
+
const { resolvePackDirOrNull } = require('./pack-resolve');
|
|
8
|
+
const { renderArea, removeSideCars, assertNamespaceMatch } = require('./render-core');
|
|
9
|
+
const { synthesizeManifest } = require('./pack-manifest');
|
|
10
|
+
const { buildTarball, EXCLUDE } = require('./tarball-builder');
|
|
11
|
+
|
|
12
|
+
// Reuse tarball-builder's exclusion set (single source of truth) for the temp-dir copy filter.
|
|
13
|
+
function isExcluded(srcPath) {
|
|
14
|
+
return EXCLUDE.has(path.basename(srcPath)) || srcPath.endsWith('.tgz');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// File sink: render into a temp build dir, tar -> <short>-<version>.tgz. It NEVER bumps -- it
|
|
18
|
+
// captures an exact snapshot at the current manifest version (synthesized = 0.1.0; owned =
|
|
19
|
+
// the pack's current version). A backup/handoff copy, never a claimed source of truth, so a
|
|
20
|
+
// shared version string is safe (a .tgz install is a direct file install, not a registry
|
|
21
|
+
// resolve). Async so the temp dir is cleaned only AFTER the tarball is fully written.
|
|
22
|
+
async function fileSink(areaRef, options) {
|
|
23
|
+
const { cwd, gameRoot, namespace, area } = options;
|
|
24
|
+
const force = !!options.force;
|
|
25
|
+
const keepSidecars = !!options.keepSidecars;
|
|
26
|
+
|
|
27
|
+
const packDir = resolvePackDirOrNull(cwd, namespace, options.pack);
|
|
28
|
+
const tmpBuild = fs.mkdtempSync(path.join(os.tmpdir(), 'tapestry-harvest-'));
|
|
29
|
+
try {
|
|
30
|
+
if (packDir) {
|
|
31
|
+
const manifest = readYaml(path.join(packDir, 'pack.yaml')) || {};
|
|
32
|
+
assertNamespaceMatch(manifest, namespace, packDir);
|
|
33
|
+
// Complete-pack copy so the .tgz carries the whole pack (incl. module code), area folded in.
|
|
34
|
+
fs.cpSync(packDir, tmpBuild, { recursive: true, filter: (src) => !isExcluded(src) });
|
|
35
|
+
} else {
|
|
36
|
+
writeYaml(path.join(tmpBuild, 'pack.yaml'), synthesizeManifest(namespace, { name: options.name }));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const { files } = renderArea(tmpBuild, { gameRoot, area, force });
|
|
40
|
+
|
|
41
|
+
// Snapshot semantics: ship at the current manifest version, never bump.
|
|
42
|
+
const manifest = readYaml(path.join(tmpBuild, 'pack.yaml'));
|
|
43
|
+
const version = manifest.version;
|
|
44
|
+
const shortName = manifest.name.split('/')[1];
|
|
45
|
+
const outputPath = options.out
|
|
46
|
+
? (path.isAbsolute(options.out) ? options.out : path.join(cwd, options.out))
|
|
47
|
+
: path.join(cwd, `${shortName}-${version}.tgz`);
|
|
48
|
+
|
|
49
|
+
await buildTarball(tmpBuild, outputPath);
|
|
50
|
+
|
|
51
|
+
if (!keepSidecars) {
|
|
52
|
+
removeSideCars(gameRoot, area, files);
|
|
53
|
+
}
|
|
54
|
+
console.log(`Harvested area '${area}' -> ${outputPath} (v${version}).`);
|
|
55
|
+
console.log('This .tgz is a portable, installable pack -- back it up, share it, or `tapestry install` it.');
|
|
56
|
+
return outputPath;
|
|
57
|
+
} finally {
|
|
58
|
+
fs.rmSync(tmpBuild, { recursive: true, force: true });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = { fileSink };
|
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 };
|
|
@@ -0,0 +1,87 @@
|
|
|
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
|
+
// Reverse of PackNamespace: split the namespace on its FIRST hyphen -> @scope/package.
|
|
34
|
+
function namespaceToName(namespace) {
|
|
35
|
+
const dash = namespace.indexOf('-');
|
|
36
|
+
if (dash < 1 || dash === namespace.length - 1) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`Cannot derive a pack name from namespace '${namespace}'. Pass --name <@scope/pack>.`);
|
|
39
|
+
}
|
|
40
|
+
const scope = namespace.slice(0, dash);
|
|
41
|
+
const pkg = namespace.slice(dash + 1);
|
|
42
|
+
return `@${scope}/${pkg}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function titleCase(s) {
|
|
46
|
+
return s.split(/[-_]/).filter(Boolean)
|
|
47
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
48
|
+
.join(' ');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Synthesize a strict-bootable world manifest for the no-pack hobbyist (exemplar B).
|
|
52
|
+
// Content globs are added by renderArea's ensureContentGlobs after content lands.
|
|
53
|
+
function synthesizeManifest(namespace, { name } = {}) {
|
|
54
|
+
const packName = name || namespaceToName(namespace);
|
|
55
|
+
const scope = packName.replace(/^@/, '').split('/')[0];
|
|
56
|
+
const pkgPart = packName.split('/')[1] || namespace;
|
|
57
|
+
return {
|
|
58
|
+
name: packName,
|
|
59
|
+
version: '0.1.0',
|
|
60
|
+
type: 'world',
|
|
61
|
+
display_name: titleCase(pkgPart),
|
|
62
|
+
description: `Harvested world pack for ${namespace}.`,
|
|
63
|
+
author: scope,
|
|
64
|
+
license: 'MIT',
|
|
65
|
+
engine: '>=0.1.0',
|
|
66
|
+
validation: 'strict',
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Bump the pack version (patch|minor|major). Returns { old, new }.
|
|
71
|
+
function bumpVersion(packDir, level = 'patch') {
|
|
72
|
+
const manifestPath = path.join(packDir, 'pack.yaml');
|
|
73
|
+
const manifest = readYaml(manifestPath) || {};
|
|
74
|
+
const old = manifest.version;
|
|
75
|
+
if (!old || !semver.valid(old)) {
|
|
76
|
+
throw new Error(`pack.yaml has no valid semver version (found: ${old}). Cannot bump.`);
|
|
77
|
+
}
|
|
78
|
+
const next = semver.inc(old, level);
|
|
79
|
+
if (!next) {
|
|
80
|
+
throw new Error(`Invalid bump level '${level}'. Expected patch, minor, or major.`);
|
|
81
|
+
}
|
|
82
|
+
manifest.version = next;
|
|
83
|
+
writeYaml(manifestPath, manifest);
|
|
84
|
+
return { old, new: next };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = { ensureContentGlobs, bumpVersion, namespaceToName, synthesizeManifest, CONTENT_GLOBS };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { readYaml } = require('../util/yaml');
|
|
5
|
+
const { readLinks } = require('./links');
|
|
6
|
+
|
|
7
|
+
// "namespace:area-id" -> { namespace, area }. Throws on a malformed ref.
|
|
8
|
+
function parseAreaRef(areaRef) {
|
|
9
|
+
const colon = (areaRef || '').indexOf(':');
|
|
10
|
+
if (colon < 1) {
|
|
11
|
+
throw new Error('Usage: <namespace:area-id> (e.g. legends-forgotten:village-green)');
|
|
12
|
+
}
|
|
13
|
+
return { namespace: areaRef.slice(0, colon), area: areaRef.slice(colon + 1) };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// "@legends/forgotten" -> "legends-forgotten" (mirrors engine PackLoader.PackNamespace).
|
|
17
|
+
function packNamespace(name) {
|
|
18
|
+
if (name.indexOf('/') === -1) {
|
|
19
|
+
return name;
|
|
20
|
+
}
|
|
21
|
+
return name.replace(/^@/, '').split('/').join('-');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// All linked pack dirs whose manifest namespace matches `namespace`.
|
|
25
|
+
function findPackMatches(cwd, namespace) {
|
|
26
|
+
const { links } = readLinks(cwd);
|
|
27
|
+
const matches = [];
|
|
28
|
+
for (const [name, dir] of Object.entries(links)) {
|
|
29
|
+
let derivedNs;
|
|
30
|
+
try {
|
|
31
|
+
const manifest = readYaml(path.join(dir, 'pack.yaml')) || {};
|
|
32
|
+
derivedNs = packNamespace(manifest.name || name);
|
|
33
|
+
} catch (e) {
|
|
34
|
+
derivedNs = packNamespace(name);
|
|
35
|
+
}
|
|
36
|
+
if (derivedNs === namespace) {
|
|
37
|
+
matches.push(dir);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return matches;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Throwing resolution (git sink: a pack MUST exist).
|
|
44
|
+
function detectPackDir(cwd, namespace, explicitPack) {
|
|
45
|
+
if (explicitPack) {
|
|
46
|
+
return path.isAbsolute(explicitPack) ? explicitPack : path.join(cwd, explicitPack);
|
|
47
|
+
}
|
|
48
|
+
const matches = findPackMatches(cwd, namespace);
|
|
49
|
+
if (matches.length === 1) {
|
|
50
|
+
return matches[0];
|
|
51
|
+
}
|
|
52
|
+
if (matches.length === 0) {
|
|
53
|
+
throw new Error(`Could not auto-detect a pack for namespace '${namespace}'. Pass --pack <dir>.`);
|
|
54
|
+
}
|
|
55
|
+
throw new Error(`Multiple linked packs match namespace '${namespace}'. Pass --pack <dir>.`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Nullable resolution (file sink: a pack MAY be absent -- hobbyist case).
|
|
59
|
+
function resolvePackDirOrNull(cwd, namespace, explicitPack) {
|
|
60
|
+
if (explicitPack) {
|
|
61
|
+
return path.isAbsolute(explicitPack) ? explicitPack : path.join(cwd, explicitPack);
|
|
62
|
+
}
|
|
63
|
+
const matches = findPackMatches(cwd, namespace);
|
|
64
|
+
return matches.length === 1 ? matches[0] : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { parseAreaRef, packNamespace, findPackMatches, detectPackDir, resolvePackDirOrNull };
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { readYaml, writeYaml } = require('../util/yaml');
|
|
6
|
+
const { ensureContentGlobs } = require('./pack-manifest');
|
|
7
|
+
const { packNamespace } = require('./pack-resolve');
|
|
8
|
+
|
|
9
|
+
// Fold one area's authored side-cars into a target pack directory. Setup-independent:
|
|
10
|
+
// it does not know whether targetDir is a real repo or a temp build dir.
|
|
11
|
+
// Returns { written, files }. Throws if there is nothing to render or a pack file diverges.
|
|
12
|
+
function renderArea(targetDir, { gameRoot, area, force = false }) {
|
|
13
|
+
const sideCarRooms = path.join(gameRoot, 'data', 'areas', area, 'rooms');
|
|
14
|
+
if (!fs.existsSync(sideCarRooms)) {
|
|
15
|
+
throw new Error(`No authored rooms found for area '${area}' at ${sideCarRooms}`);
|
|
16
|
+
}
|
|
17
|
+
const files = fs.readdirSync(sideCarRooms).filter((f) => f.endsWith('.yaml'));
|
|
18
|
+
if (files.length === 0) {
|
|
19
|
+
throw new Error(`No authored rooms found for area '${area}'`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const targetRooms = path.join(targetDir, 'areas', area, 'rooms');
|
|
23
|
+
fs.mkdirSync(targetRooms, { recursive: true });
|
|
24
|
+
|
|
25
|
+
// area.yaml: copy the authored envelope through; else leave the pack's; else synthesize.
|
|
26
|
+
const sideCarAreaYaml = path.join(gameRoot, 'data', 'areas', area, 'area.yaml');
|
|
27
|
+
const targetAreaYaml = path.join(targetDir, 'areas', area, 'area.yaml');
|
|
28
|
+
if (fs.existsSync(sideCarAreaYaml)) {
|
|
29
|
+
writeYaml(targetAreaYaml, readYaml(sideCarAreaYaml));
|
|
30
|
+
} else if (!fs.existsSync(targetAreaYaml)) {
|
|
31
|
+
writeYaml(targetAreaYaml, {
|
|
32
|
+
area: { id: area, name: area, level_range: [1, 99], reset_interval: 300 },
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let written = 0;
|
|
37
|
+
for (const file of files) {
|
|
38
|
+
const src = path.join(sideCarRooms, file);
|
|
39
|
+
const dest = path.join(targetRooms, file);
|
|
40
|
+
const incoming = readYaml(src);
|
|
41
|
+
if (fs.existsSync(dest) && !force) {
|
|
42
|
+
const existing = readYaml(dest);
|
|
43
|
+
if (JSON.stringify(existing) !== JSON.stringify(incoming)) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
`Pack file ${dest} diverges from the side-car. Review the diff and re-run with --force to overwrite.`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
writeYaml(dest, incoming);
|
|
49
|
+
written++;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
ensureContentGlobs(targetDir);
|
|
53
|
+
reconcileDependencies(targetDir, area);
|
|
54
|
+
|
|
55
|
+
return { written, files };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Designed-in seam (design section 6): scan harvested content for cross-pack references and
|
|
59
|
+
// write a `dependencies` block into the target pack.yaml, hard-erroring on the unresolvable.
|
|
60
|
+
// Rooms are self-contained (their only cross-pack edge is the runtime-only `link` seam, which
|
|
61
|
+
// never bakes into the artifact), so this is a deliberate NO-OP until referential side-cars
|
|
62
|
+
// (mobs/items/quests) exist. Wired now so the slice-5 dependency stage slots in, not bolts on.
|
|
63
|
+
function reconcileDependencies(targetDir, area) {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Move semantics: delete the game-root side-cars for an area once the content is durably
|
|
68
|
+
// promoted. Idempotent; prunes the rooms/ and area dirs when they go empty.
|
|
69
|
+
function removeSideCars(gameRoot, area, files) {
|
|
70
|
+
const sideCarRooms = path.join(gameRoot, 'data', 'areas', area, 'rooms');
|
|
71
|
+
const areaSideCarDir = path.join(gameRoot, 'data', 'areas', area);
|
|
72
|
+
for (const file of files) {
|
|
73
|
+
const p = path.join(sideCarRooms, file);
|
|
74
|
+
if (fs.existsSync(p)) {
|
|
75
|
+
fs.rmSync(p);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (fs.existsSync(sideCarRooms) && fs.readdirSync(sideCarRooms).length === 0) {
|
|
79
|
+
fs.rmdirSync(sideCarRooms);
|
|
80
|
+
}
|
|
81
|
+
const areaYaml = path.join(areaSideCarDir, 'area.yaml');
|
|
82
|
+
if (fs.existsSync(areaYaml)) {
|
|
83
|
+
fs.rmSync(areaYaml);
|
|
84
|
+
}
|
|
85
|
+
if (fs.existsSync(areaSideCarDir) && fs.readdirSync(areaSideCarDir).length === 0) {
|
|
86
|
+
fs.rmdirSync(areaSideCarDir);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Namespace guard: a sink may only fold an area into its OWN pack. Throws on mismatch.
|
|
91
|
+
function assertNamespaceMatch(manifest, namespace, packDir) {
|
|
92
|
+
if (!manifest.name) {
|
|
93
|
+
throw new Error(`pack.yaml in ${packDir} has no 'name' field.`);
|
|
94
|
+
}
|
|
95
|
+
const destNamespace = packNamespace(manifest.name);
|
|
96
|
+
if (destNamespace !== namespace) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`Pack namespace '${destNamespace}' does not match area namespace '${namespace}'. ` +
|
|
99
|
+
'harvest only commits an area back into its own pack; not-owned content forks (a later slice).');
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = { renderArea, reconcileDependencies, removeSideCars, assertNamespaceMatch };
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { readYaml } = require('../util/yaml');
|
|
6
|
+
const { resolvePackDirOrNull } = require('./pack-resolve');
|
|
7
|
+
|
|
8
|
+
// The one state vocabulary (design section 7), spec'd here for the CLI view. The in-game
|
|
9
|
+
// `areas` view shows engine provenance tags ([pack]/[authored]/[pack +edits]) plus a WIP
|
|
10
|
+
// tag; both are views of these same on-disk facts. Strict boot stays the authority.
|
|
11
|
+
const STATES = { CLEAN: 'Clean', EDITED: 'Edited', FORK: 'Fork', WIP: 'WIP' };
|
|
12
|
+
const WIP_FLAG = 'wip';
|
|
13
|
+
|
|
14
|
+
function areaIsWip(areaDir) {
|
|
15
|
+
const areaYaml = path.join(areaDir, 'area.yaml');
|
|
16
|
+
if (!fs.existsSync(areaYaml)) {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
const env = readYaml(areaYaml) || {};
|
|
20
|
+
const flags = (env.area && env.area.flags) || [];
|
|
21
|
+
return Array.isArray(flags) && flags.includes(WIP_FLAG);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Lighter mirror of in-game `areas`: classify each authored area from side-car facts alone.
|
|
25
|
+
// Never queries the registry or resolves the reference graph.
|
|
26
|
+
function computeAreaStates(cwd, gameRoot) {
|
|
27
|
+
const areasRoot = path.join(gameRoot, 'data', 'areas');
|
|
28
|
+
if (!fs.existsSync(areasRoot)) {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
const rows = [];
|
|
32
|
+
for (const area of fs.readdirSync(areasRoot)) {
|
|
33
|
+
const areaDir = path.join(areasRoot, area);
|
|
34
|
+
if (!fs.statSync(areaDir).isDirectory()) {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const roomsDir = path.join(areaDir, 'rooms');
|
|
38
|
+
const roomFiles = fs.existsSync(roomsDir)
|
|
39
|
+
? fs.readdirSync(roomsDir).filter((f) => f.endsWith('.yaml'))
|
|
40
|
+
: [];
|
|
41
|
+
|
|
42
|
+
// Namespace is carried in the side-car room ids (ns:key).
|
|
43
|
+
let namespace = null;
|
|
44
|
+
if (roomFiles.length) {
|
|
45
|
+
const first = readYaml(path.join(roomsDir, roomFiles[0])) || {};
|
|
46
|
+
if (typeof first.id === 'string' && first.id.includes(':')) {
|
|
47
|
+
namespace = first.id.split(':')[0];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const wip = areaIsWip(areaDir);
|
|
52
|
+
const packDir = namespace ? resolvePackDirOrNull(cwd, namespace, undefined) : null;
|
|
53
|
+
const owned = !!packDir;
|
|
54
|
+
|
|
55
|
+
let state;
|
|
56
|
+
if (wip) {
|
|
57
|
+
state = STATES.WIP;
|
|
58
|
+
} else if (roomFiles.length === 0) {
|
|
59
|
+
state = STATES.CLEAN;
|
|
60
|
+
} else if (!owned) {
|
|
61
|
+
// Namespace maps to no pack you own (official/foreign) -> a fork target (slice 3).
|
|
62
|
+
state = STATES.FORK;
|
|
63
|
+
} else {
|
|
64
|
+
const edited = roomFiles.some((f) => {
|
|
65
|
+
const packRoom = path.join(packDir, 'areas', area, 'rooms', f);
|
|
66
|
+
if (!fs.existsSync(packRoom)) {
|
|
67
|
+
return true; // new room not yet in the pack
|
|
68
|
+
}
|
|
69
|
+
return JSON.stringify(readYaml(packRoom)) !== JSON.stringify(readYaml(path.join(roomsDir, f)));
|
|
70
|
+
});
|
|
71
|
+
state = edited ? STATES.EDITED : STATES.CLEAN;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
rows.push({ area, namespace, state, roomCount: roomFiles.length, wip });
|
|
75
|
+
}
|
|
76
|
+
return rows.sort((a, b) => a.area.localeCompare(b.area));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = { computeAreaStates, STATES, WIP_FLAG };
|
|
@@ -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(),
|