@tapestry-mud/cli 0.10.0 → 0.12.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
@@ -411,15 +411,15 @@ async function runHarvest(areaRef, opts) {
411
411
  program
412
412
  .command('harvest <areaRef>')
413
413
  .description('Harvest an authored area into a portable pack (areaRef = namespace:area-id)')
414
- .option('--sink <sink>', 'Output sink: file | git (auto-detected by default)')
414
+ .option('--sink <sink>', 'Output sink: file | git | registry (auto-detected by default)')
415
415
  .option('--out <path>', '(file sink) where the .tgz lands')
416
- .option('--name <name>', '(file sink) override the synthesized pack name (@scope/pack)')
416
+ .option('--name <name>', '(file/registry sink) override the synthesized pack name (@scope/pack)')
417
417
  .option('--pack <dir>', 'Target pack directory (auto-detected from linked packs by default)')
418
418
  .option('--game-root <path>', 'Game root containing data/ (default: current dir)')
419
419
  .option('--keep-sidecars', 'Copy instead of move (leave the game-root side-cars in place)')
420
420
  .option('--force', 'Overwrite pack files that diverge from the side-car')
421
- .option('--minor', 'Bump the pack minor version (git sink only; default: patch)')
422
- .option('--major', 'Bump the pack major version (git sink only; default: patch)')
421
+ .option('--minor', 'Bump the pack minor version (git/registry sink, owned pack only; default: patch)')
422
+ .option('--major', 'Bump the pack major version (git/registry sink, owned pack only; default: patch)')
423
423
  .action(runHarvest);
424
424
 
425
425
  // Deprecated: sync-area is now harvest --sink git.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tapestry-mud/cli",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "CLI for the Tapestry MUD engine",
5
5
  "repository": {
6
6
  "type": "git",
@@ -18,7 +18,7 @@
18
18
  },
19
19
  "dependencies": {
20
20
  "commander": "11.1.0",
21
- "form-data": "4.0.5",
21
+ "form-data": "4.0.6",
22
22
  "inquirer": "8.2.7",
23
23
  "js-yaml": "4.1.1",
24
24
  "node-fetch": "2.7.0",
package/specs/README.md CHANGED
@@ -9,7 +9,7 @@ behave?" from the relevant file alone.
9
9
 
10
10
  | Capability | File | Last Updated |
11
11
  |------------|------|--------------|
12
- | pack-lifecycle | [pack-lifecycle.md](pack-lifecycle.md) | 2026-06-13 |
12
+ | pack-lifecycle | [pack-lifecycle.md](pack-lifecycle.md) | 2026-06-20 |
13
13
  | validate | [validate.md](validate.md) | 2026-06-13 |
14
14
  | harvest | [harvest.md](harvest.md) | 2026-06-13 |
15
15
  | registry-auth | [registry-auth.md](registry-auth.md) | 2026-06-13 |
@@ -0,0 +1,28 @@
1
+ ---
2
+ release: 0.10.0
3
+ specs: [pack-lifecycle.md]
4
+ ---
5
+
6
+ # Pack ESM Build
7
+
8
+ ## Why
9
+
10
+ Packs are authored in TypeScript and ship native ES modules, but the CLI had no
11
+ build step: `pack` and `publish` archived whatever was on disk. An ESM pack
12
+ declares `content.scripts_format: esm` and points `content.scripts` at compiled
13
+ output under `dist/scripts/`, which is gitignored - so without a compile the
14
+ tarball shipped no runnable scripts. Pack authors also had no typed surface for
15
+ the engine API they call, so editors could not check `tapestry.*` usage.
16
+
17
+ ## What
18
+
19
+ - `pack` and `publish` compile an ESM pack's `scripts/**/*.ts` to
20
+ `dist/scripts/**/*.js` with the bundled `tsc` and the pack's `tsconfig.json`
21
+ before building the tarball. The compile runs before any network or auth work
22
+ in `publish`. Legacy packs (no `scripts_format: esm`) are a no-op, so the step
23
+ is backward compatible.
24
+ - New `tapestry types` command vendors the `@tapestry/engine` type definitions
25
+ into a project's `types/` so `tsc` and editors can type-check pack scripts
26
+ against the engine API surface the pack calls.
27
+ - `typescript` is pinned exactly as a CLI dependency (no caret) so the compile is
28
+ reproducible.
package/specs/harvest.md CHANGED
@@ -133,12 +133,20 @@ current version. (src/lib/file-sink.js:19-21)
133
133
  - Without `--force`: throws if a room file in the target differs from the incoming side-car.
134
134
  (src/lib/render-core.js:41-45)
135
135
  - With `--force`: overwrites divergent room files silently. (src/lib/render-core.js:40-49)
136
- - After writing, calls `ensureContentGlobs` to additively add `area_definitions` and `rooms`
137
- glob entries to `pack.yaml` if not already present. (src/lib/render-core.js:52)
138
- (src/lib/pack-manifest.js:7-31)
136
+ - After writing rooms, copies oracle side-car files: `places-oracle.yaml` at the area root and
137
+ any `*-oracle-table.yaml` files anywhere under the area tree (including inside `mobs/` and
138
+ `items/` subdirectories). The same divergence guard (throw without `--force`, overwrite with
139
+ `--force`) applies. (src/lib/render-core.js) (src/lib/pack-manifest.js)
140
+ - After copying oracle side-cars, copies all `*.yaml` files from `mobs/` and `items/` under the
141
+ area side-car tree into the corresponding `areas/<area>/mobs/` and `areas/<area>/items/`
142
+ directories in the target pack, including any `*-oracle-table.yaml` files in those directories.
143
+ The same divergence guard applies. (src/lib/render-core.js)
144
+ - After writing, calls `ensureContentGlobs` to additively add `area_definitions`, `rooms`,
145
+ `oracle_tables`, `places_oracle`, `mobs`, and `items` glob entries to `pack.yaml` if not
146
+ already present. (src/lib/render-core.js) (src/lib/pack-manifest.js)
139
147
  - `reconcileDependencies` is a deliberate no-op: rooms are self-contained; the seam is wired
140
148
  for a future slice that will scan for cross-pack references in mobs/items/quests.
141
- (src/lib/render-core.js:63-65)
149
+ (src/lib/render-core.js)
142
150
 
143
151
  ### Side-car removal (default behavior)
144
152
 
@@ -156,4 +164,6 @@ empty. `area.yaml` in the game root is also deleted. (src/lib/render-core.js:69-
156
164
 
157
165
  ## Change Log
158
166
 
159
- - None on record.
167
+ - 2026-06-23 (0.11.0): renderArea carries oracle table side-cars (places-oracle.yaml, *-oracle-table.yaml)
168
+ and mints mob/item instance files (mobs/*.yaml, items/*.yaml) into the target pack alongside
169
+ area.yaml and rooms/. CONTENT_GLOBS gains oracle_tables, places_oracle, mobs, items.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  capability: pack-lifecycle
3
- last-updated: 2026-06-13
3
+ last-updated: 2026-06-20
4
4
  ---
5
5
 
6
6
  # pack-lifecycle
@@ -203,4 +203,4 @@ order file is `tapestry-boot.yaml`; links are tracked in `tapestry-links.yaml`.
203
203
 
204
204
  ## Change Log
205
205
 
206
- - None on record.
206
+ - 2026-06-20 (0.10.0): `pack` and `publish` compile ESM packs (`scripts_format: esm`) via `tsc` before archiving; new `tapestry types` command vendors the engine `.d.ts`. See changes/2026-06-20-pack-esm-build.md.
@@ -4,6 +4,7 @@ const { parseAreaRef, resolvePackDirOrNull } = require('../lib/pack-resolve');
4
4
  const { isRepo } = require('../lib/git');
5
5
  const { syncArea } = require('./sync-area');
6
6
  const { fileSink } = require('../lib/file-sink');
7
+ const { registrySink } = require('../lib/registry-sink');
7
8
 
8
9
  // Umbrella harvest verb. Auto-detects the sink (owned linked pack that is a git repo -> git;
9
10
  // else file) unless --sink is explicit. The render core is shared by every sink.
@@ -23,14 +24,23 @@ async function harvest(areaRef, options = {}) {
23
24
  return syncArea(areaRef, options);
24
25
  }
25
26
  if (sink === 'file') {
26
- // The file sink snapshots at the current version -- it ignores --minor/--major (git-sink only).
27
+ // The file sink snapshots at the current version -- it ignores --minor/--major.
27
28
  return fileSink(areaRef, {
28
29
  cwd, gameRoot, namespace, area,
29
30
  force: options.force, keepSidecars: options.keepSidecars,
30
31
  out: options.out, name: options.name, pack: options.pack,
31
32
  });
32
33
  }
33
- throw new Error(`Unknown sink '${sink}'. Use 'file' or 'git'.`);
34
+ if (sink === 'registry') {
35
+ return registrySink(areaRef, {
36
+ cwd, gameRoot, namespace, area,
37
+ force: options.force, keepSidecars: options.keepSidecars,
38
+ pack: options.pack, name: options.name,
39
+ bump: options.bump,
40
+ registryUrl: options.registryUrl,
41
+ });
42
+ }
43
+ throw new Error(`Unknown sink '${sink}'. Use 'file', 'git', or 'registry'.`);
34
44
  }
35
45
 
36
46
  module.exports = { harvest };
@@ -7,6 +7,10 @@ const { readYaml, writeYaml } = require('../util/yaml');
7
7
  const CONTENT_GLOBS = {
8
8
  area_definitions: 'areas/**/area.yaml',
9
9
  rooms: 'areas/**/rooms/*.yaml',
10
+ oracle_tables: 'areas/**/*-oracle-table.yaml',
11
+ places_oracle: 'areas/**/places-oracle.yaml',
12
+ mobs: 'areas/**/mobs/*.yaml',
13
+ items: 'areas/**/items/*.yaml',
10
14
  };
11
15
 
12
16
  // Additively ensure the pack manifest declares the given content globs.
@@ -0,0 +1,117 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const fetch = require('node-fetch');
7
+ const FormData = require('form-data');
8
+ const { readYaml, writeYaml } = require('../util/yaml');
9
+ const { resolvePackDirOrNull } = require('./pack-resolve');
10
+ const { renderArea, removeSideCars, assertNamespaceMatch } = require('./render-core');
11
+ const { synthesizeManifest, bumpVersion } = require('./pack-manifest');
12
+ const { buildTarball, computeIntegrity } = require('./tarball-builder');
13
+ const { isRepo } = require('./git');
14
+ const { requireAccess } = require('./auth');
15
+ const { DEFAULT_REGISTRY, throwIfError } = require('./registry-client');
16
+
17
+ // Registry sink: render -> tar -> POST /v1/publish.
18
+ // Run where the registry token lives (operator machine or no-git server).
19
+ // Refuses registry-direct when the linked pack is repo-backed (source-of-truth trap).
20
+ //
21
+ // Owned (linked pack, not git repo): mirrors the git sink. Renders into the REAL pack dir
22
+ // so content accumulates in the operator's source of truth across repeated harvests; bumps;
23
+ // tars+POSTs. Edge: if POST fails after render+bump, the real pack holds both - re-running
24
+ // re-renders to a no-op and re-bumps (a registry version gap, accepted, source truth intact).
25
+ //
26
+ // Hobbyist (no linked pack): mirrors the file sink. Synthesizes a manifest, renders into a
27
+ // temp dir, tars+POSTs. No persistent bump. A second publish at 0.1.0 will fail at the
28
+ // registry - the intended signal to set up a real pack.
29
+ async function registrySink(areaRef, options) {
30
+ const { cwd, gameRoot, namespace, area } = options;
31
+ const force = !!options.force;
32
+ const keepSidecars = !!options.keepSidecars;
33
+ const registryUrl = options.registryUrl || DEFAULT_REGISTRY;
34
+
35
+ const packDir = resolvePackDirOrNull(cwd, namespace, options.pack);
36
+
37
+ if (packDir && isRepo(packDir)) {
38
+ throw new Error(
39
+ `Cannot publish registry-direct: '${packDir}' is a git repo.\n` +
40
+ `Harvest to file instead: tapestry harvest ${areaRef} --sink file\n` +
41
+ `Pull the .tgz to the machine that owns the repo, unpack into the repo, commit, push,\n` +
42
+ `and let CI publish.`
43
+ );
44
+ }
45
+
46
+ let files;
47
+ let manifest;
48
+ let tmpBuild = null;
49
+ let tmpTgz;
50
+
51
+ try {
52
+ if (packDir) {
53
+ const existingManifest = readYaml(path.join(packDir, 'pack.yaml')) || {};
54
+ assertNamespaceMatch(existingManifest, namespace, packDir);
55
+
56
+ // Fail loudly on EACCES before any mutation - never silently.
57
+ try {
58
+ fs.accessSync(packDir, fs.constants.W_OK);
59
+ } catch (e) {
60
+ throw new Error(
61
+ `Cannot write to pack directory ${packDir}: ${e.message}. ` +
62
+ `The user running tapestry may not own that directory.`
63
+ );
64
+ }
65
+
66
+ // Render into the REAL pack dir (content accumulates), then bump.
67
+ ({ files } = renderArea(packDir, { gameRoot, area, force }));
68
+ bumpVersion(packDir, options.bump || 'patch');
69
+ manifest = readYaml(path.join(packDir, 'pack.yaml'));
70
+ } else {
71
+ tmpBuild = fs.mkdtempSync(path.join(os.tmpdir(), 'tapestry-harvest-'));
72
+ manifest = synthesizeManifest(namespace, { name: options.name });
73
+ writeYaml(path.join(tmpBuild, 'pack.yaml'), manifest);
74
+ ({ files } = renderArea(tmpBuild, { gameRoot, area, force }));
75
+ // Re-read after render in case ensureContentGlobs updated the manifest.
76
+ manifest = readYaml(path.join(tmpBuild, 'pack.yaml'));
77
+ }
78
+
79
+ const buildDir = tmpBuild || packDir;
80
+ const shortName = manifest.name.split('/')[1];
81
+ tmpTgz = path.join(os.tmpdir(), `tapestry-publish-${shortName}-${manifest.version}.tgz`);
82
+
83
+ await buildTarball(buildDir, tmpTgz);
84
+ const integrity = computeIntegrity(tmpTgz);
85
+ const token = await requireAccess();
86
+
87
+ const form = new FormData();
88
+ form.append('tarball', fs.createReadStream(tmpTgz), {
89
+ filename: `${manifest.version}.tgz`,
90
+ contentType: 'application/gzip',
91
+ });
92
+ form.append('metadata', JSON.stringify({ ...manifest, integrity }));
93
+
94
+ const res = await fetch(`${registryUrl}/v1/publish`, {
95
+ method: 'POST',
96
+ headers: { ...form.getHeaders(), Authorization: `Bearer ${token}` },
97
+ body: form,
98
+ });
99
+ await throwIfError(res, 'Publish failed');
100
+ const result = await res.json();
101
+
102
+ if (!keepSidecars) {
103
+ removeSideCars(gameRoot, area, files);
104
+ }
105
+ console.log(`Harvested area '${area}' and published ${result.name}@${result.version}.`);
106
+ console.log('Run `tapestry update` on your game server to pull the new version.');
107
+ } finally {
108
+ if (tmpTgz && fs.existsSync(tmpTgz)) {
109
+ fs.unlinkSync(tmpTgz);
110
+ }
111
+ if (tmpBuild) {
112
+ fs.rmSync(tmpBuild, { recursive: true, force: true });
113
+ }
114
+ }
115
+ }
116
+
117
+ module.exports = { registrySink };
@@ -6,6 +6,44 @@ const { readYaml, writeYaml } = require('../util/yaml');
6
6
  const { ensureContentGlobs } = require('./pack-manifest');
7
7
  const { packNamespace } = require('./pack-resolve');
8
8
 
9
+ // Recursively collect files matching a name predicate under a directory.
10
+ // Returns absolute paths.
11
+ function collectFiles(dir, predicate, results = []) {
12
+ if (!fs.existsSync(dir)) {
13
+ return results;
14
+ }
15
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
16
+ if (entry.isDirectory()) {
17
+ collectFiles(path.join(dir, entry.name), predicate, results);
18
+ } else if (predicate(entry.name)) {
19
+ results.push(path.join(dir, entry.name));
20
+ }
21
+ }
22
+ return results;
23
+ }
24
+
25
+ // Copy oracle side-car yaml files from the area source root into the area dest root,
26
+ // preserving relative paths and applying the divergence guard.
27
+ function copyOracleSideCars(areaSrcRoot, areaDestRoot, force) {
28
+ const oracleFiles = collectFiles(areaSrcRoot, (name) => {
29
+ return name === 'places-oracle.yaml' || name.endsWith('-oracle-table.yaml');
30
+ });
31
+ for (const src of oracleFiles) {
32
+ const rel = path.relative(areaSrcRoot, src);
33
+ const dest = path.join(areaDestRoot, rel);
34
+ const incoming = readYaml(src);
35
+ if (fs.existsSync(dest) && !force) {
36
+ const existing = readYaml(dest);
37
+ if (JSON.stringify(existing) !== JSON.stringify(incoming)) {
38
+ throw new Error(
39
+ `Pack file ${dest} diverges from the side-car. Review the diff and re-run with --force to overwrite.`);
40
+ }
41
+ }
42
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
43
+ writeYaml(dest, incoming);
44
+ }
45
+ }
46
+
9
47
  // Fold one area's authored side-cars into a target pack directory. Setup-independent:
10
48
  // it does not know whether targetDir is a real repo or a temp build dir.
11
49
  // Returns { written, files }. Throws if there is nothing to render or a pack file diverges.
@@ -49,6 +87,35 @@ function renderArea(targetDir, { gameRoot, area, force = false }) {
49
87
  written++;
50
88
  }
51
89
 
90
+ // Copy oracle table side-cars: places-oracle.yaml at the area root,
91
+ // and any *-oracle-table.yaml files in subdirectories (mobs/, items/, etc.).
92
+ const areaSrcRoot = path.join(gameRoot, 'data', 'areas', area);
93
+ const areaDestRoot = path.join(targetDir, 'areas', area);
94
+ copyOracleSideCars(areaSrcRoot, areaDestRoot, force);
95
+
96
+ // Copy minted mob and item instance files (including their *-oracle-table.yaml).
97
+ for (const sub of ['mobs', 'items']) {
98
+ const srcDir = path.join(gameRoot, 'data', 'areas', area, sub);
99
+ if (fs.existsSync(srcDir)) {
100
+ const subFiles = fs.readdirSync(srcDir).filter((f) => f.endsWith('.yaml'));
101
+ const destDir = path.join(targetDir, 'areas', area, sub);
102
+ fs.mkdirSync(destDir, { recursive: true });
103
+ for (const file of subFiles) {
104
+ const src = path.join(srcDir, file);
105
+ const dest = path.join(destDir, 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
+ }
116
+ }
117
+ }
118
+
52
119
  ensureContentGlobs(targetDir);
53
120
  reconcileDependencies(targetDir, area);
54
121