@tapestry-mud/cli 0.8.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 CHANGED
@@ -31,6 +31,8 @@ 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
33
  const { syncArea } = require('../src/commands/sync-area');
34
+ const { harvest } = require('../src/commands/harvest');
35
+ const { status } = require('../src/commands/status');
34
36
 
35
37
  const program = new Command();
36
38
 
@@ -60,7 +62,7 @@ program.configureHelp({
60
62
  },
61
63
  {
62
64
  title: 'Pack Authoring',
63
- commands: ['create', 'validate', 'pack', 'publish', 'unpublish', 'sync-area'],
65
+ commands: ['create', 'validate', 'pack', 'publish', 'unpublish', 'harvest', 'status'],
64
66
  },
65
67
  {
66
68
  title: 'Trusted Publishing',
@@ -361,12 +363,28 @@ program
361
363
  }
362
364
  });
363
365
 
364
- function runSyncArea(areaRef, opts) {
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) {
365
380
  try {
366
- syncArea(areaRef, {
381
+ await harvest(areaRef, {
367
382
  cwd: process.cwd(),
368
383
  gameRoot: opts.gameRoot,
369
384
  pack: opts.pack,
385
+ sink: opts.sink,
386
+ out: opts.out,
387
+ name: opts.name,
370
388
  force: opts.force,
371
389
  keepSidecars: opts.keepSidecars,
372
390
  bump: opts.major ? 'major' : opts.minor ? 'minor' : 'patch',
@@ -377,20 +395,38 @@ function runSyncArea(areaRef, opts) {
377
395
  }
378
396
  }
379
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.
380
413
  program
381
414
  .command('sync-area <areaRef>')
382
- .description('Commit a game-root authored area back into its pack (areaRef = namespace:area-id)')
415
+ .description('(deprecated) alias for harvest --sink git')
383
416
  .option('--pack <dir>', 'Target pack directory (auto-detected from linked packs by default)')
384
417
  .option('--game-root <path>', 'Game root containing data/ (default: current dir)')
385
418
  .option('--keep-sidecars', 'Copy instead of move (leave the game-root side-cars in place)')
386
419
  .option('--force', 'Overwrite pack files that diverge from the side-car')
387
420
  .option('--minor', 'Bump the pack minor version (default: patch)')
388
421
  .option('--major', 'Bump the pack major version (default: patch)')
389
- .action(runSyncArea);
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
+ });
390
426
 
391
427
  program
392
428
  .command('export-area <areaRef>', { hidden: true })
393
- .description('(deprecated) alias for sync-area')
429
+ .description('(deprecated) alias for harvest --sink git')
394
430
  .option('--pack <dir>', 'Target pack directory (auto-detected from linked packs by default)')
395
431
  .option('--game-root <path>', 'Game root containing data/ (default: current dir)')
396
432
  .option('--keep-sidecars', 'Copy instead of move (leave the game-root side-cars in place)')
@@ -398,8 +434,8 @@ program
398
434
  .option('--minor', 'Bump the pack minor version (default: patch)')
399
435
  .option('--major', 'Bump the pack major version (default: patch)')
400
436
  .action((areaRef, opts) => {
401
- console.warn('warning: `export-area` is deprecated; use `sync-area`.');
402
- runSyncArea(areaRef, opts);
437
+ console.warn('warning: `export-area` is deprecated; use `harvest <area> --sink git`.');
438
+ runHarvest(areaRef, Object.assign({ sink: 'git' }, opts));
403
439
  });
404
440
 
405
441
  program
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tapestry-mud/cli",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "CLI for the Tapestry MUD engine",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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 };
@@ -2,45 +2,11 @@
2
2
 
3
3
  const fs = require('fs');
4
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');
5
+ const { readYaml } = require('../util/yaml');
6
+ const { bumpVersion } = require('../lib/pack-manifest');
8
7
  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
- }
8
+ const { packNamespace, detectPackDir, parseAreaRef } = require('../lib/pack-resolve');
9
+ const { renderArea, removeSideCars, assertNamespaceMatch } = require('../lib/render-core');
44
10
 
45
11
  function syncArea(areaRef, options) {
46
12
  options = options || {};
@@ -50,72 +16,17 @@ function syncArea(areaRef, options) {
50
16
  const bumpLevel = options.bump || 'patch';
51
17
  const keepSidecars = !!options.keepSidecars;
52
18
 
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
- }
19
+ const { namespace, area } = parseAreaRef(areaRef);
68
20
 
69
21
  const packDir = detectPackDir(cwd, namespace, options.pack);
70
-
71
22
  const destManifestPath = path.join(packDir, 'pack.yaml');
72
23
  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.`);
24
+ throw new Error(`No pack.yaml found in ${packDir}. harvest --sink git targets an existing pack; pass --pack <dir> pointing at one.`);
74
25
  }
75
26
  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
- }
27
+ assertNamespaceMatch(destManifest, namespace, packDir);
117
28
 
118
- ensureContentGlobs(packDir);
29
+ const { written, files } = renderArea(packDir, { gameRoot, area, force });
119
30
 
120
31
  const { old, new: next } = bumpVersion(packDir, bumpLevel);
121
32
  let committed = false;
@@ -126,24 +37,8 @@ function syncArea(areaRef, options) {
126
37
  console.warn(`warn: ${packDir} is not a git repo; bumped to ${next} but did not commit.`);
127
38
  }
128
39
 
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
40
  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
- }
41
+ removeSideCars(gameRoot, area, files);
147
42
  }
148
43
 
149
44
  console.log(`Synced ${written} room(s) for area '${area}' into ${packDir} (v${old} -> v${next}).`);
@@ -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 };
@@ -30,6 +30,43 @@ function ensureContentGlobs(packDir, globs = CONTENT_GLOBS) {
30
30
  return added;
31
31
  }
32
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
+
33
70
  // Bump the pack version (patch|minor|major). Returns { old, new }.
34
71
  function bumpVersion(packDir, level = 'patch') {
35
72
  const manifestPath = path.join(packDir, 'pack.yaml');
@@ -47,4 +84,4 @@ function bumpVersion(packDir, level = 'patch') {
47
84
  return { old, new: next };
48
85
  }
49
86
 
50
- module.exports = { ensureContentGlobs, bumpVersion, CONTENT_GLOBS };
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 };
@@ -32,4 +32,4 @@ function computeIntegrity(filePath) {
32
32
  return `sha256-${hash}`;
33
33
  }
34
34
 
35
- module.exports = { buildTarball, computeIntegrity };
35
+ module.exports = { buildTarball, computeIntegrity, EXCLUDE };
@@ -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 };