@tapestry-mud/cli 0.8.0 → 0.10.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.
@@ -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,22 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const { spawnSync } = require('child_process');
5
+
6
+ // Compiles an ESM pack's TypeScript (scripts/**/*.ts -> dist/scripts/**/*.js) using the
7
+ // bundled tsc and the pack's tsconfig.json. No-op for legacy packs (raw .js, no build).
8
+ function buildTypeScript(cwd, manifest) {
9
+ const format = manifest && manifest.content && manifest.content.scripts_format;
10
+ if (format !== 'esm') {
11
+ return;
12
+ }
13
+ const tsc = require.resolve('typescript/bin/tsc');
14
+ const tsconfig = path.join(cwd, 'tsconfig.json');
15
+ console.log('Compiling TypeScript (tsc)...');
16
+ const res = spawnSync(process.execPath, [tsc, '-p', tsconfig], { cwd, stdio: 'inherit' });
17
+ if (res.status !== 0) {
18
+ throw new Error('TypeScript compile failed');
19
+ }
20
+ }
21
+
22
+ module.exports = { buildTypeScript };
@@ -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 };