@tapestry-mud/cli 0.7.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/bin/tapestry.js CHANGED
@@ -30,6 +30,7 @@ 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');
33
34
 
34
35
  const program = new Command();
35
36
 
@@ -59,7 +60,7 @@ program.configureHelp({
59
60
  },
60
61
  {
61
62
  title: 'Pack Authoring',
62
- commands: ['create', 'validate', 'pack', 'publish', 'unpublish'],
63
+ commands: ['create', 'validate', 'pack', 'publish', 'unpublish', 'sync-area'],
63
64
  },
64
65
  {
65
66
  title: 'Trusted Publishing',
@@ -360,6 +361,47 @@ program
360
361
  }
361
362
  });
362
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
+
363
405
  program
364
406
  .command('search <query>')
365
407
  .description('Search the registry by keyword')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tapestry-mud/cli",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "CLI for the Tapestry MUD engine",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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 };
@@ -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
- fs.rmSync(destDir, { recursive: true });
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
- dockerStart(config.projectName, config.image, tag, packsDir, serverYamlPath, dataDir, config.network, dockerLinkMounts(cwd));
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 };
@@ -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 };
@@ -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
- areas: "areas/**/area.yaml"
40
+ area_definitions: "areas/**/area.yaml"
43
41
  rooms: "areas/**/rooms/*.yaml"
44
42
  items: "areas/**/items/*.yaml"
45
43
  mobs: "areas/**/mobs/*.yaml"
@@ -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.union([
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(),