@series-inc/stowkit-cli 0.1.15 → 0.1.16

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,4 @@
1
+ export declare function renameAsset(projectDir: string, assetPath: string, newName: string): Promise<void>;
2
+ export declare function moveAsset(projectDir: string, assetPath: string, targetFolder: string): Promise<void>;
3
+ export declare function deleteAsset(projectDir: string, assetPath: string): Promise<void>;
4
+ export declare function setStringId(projectDir: string, assetPath: string, newStringId: string): Promise<void>;
@@ -0,0 +1,122 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as path from 'node:path';
3
+ import { readProjectConfig, renameFile, deleteFile, readFile, } from './node-fs.js';
4
+ import { readStowmeta, writeStowmeta, } from './app/stowmeta-io.js';
5
+ // ─── Rename ──────────────────────────────────────────────────────────────────
6
+ export async function renameAsset(projectDir, assetPath, newName) {
7
+ const config = await readProjectConfig(projectDir);
8
+ // Build new ID: same folder, new filename (preserve original extension)
9
+ const folder = assetPath.includes('/') ? assetPath.slice(0, assetPath.lastIndexOf('/') + 1) : '';
10
+ const oldBase = assetPath.split('/').pop() ?? assetPath;
11
+ const extMatch = oldBase.match(/\.[^.]+$/);
12
+ const ext = extMatch ? extMatch[0] : '';
13
+ const fullNewName = ext && !newName.endsWith(ext) ? newName + ext : newName;
14
+ const newId = folder + fullNewName;
15
+ if (newId === assetPath) {
16
+ console.log('Nothing to rename — same name.');
17
+ return;
18
+ }
19
+ // Verify source exists
20
+ const sourceData = await readFile(config.srcArtDir, assetPath);
21
+ if (!sourceData) {
22
+ console.error(`Asset not found: ${assetPath}`);
23
+ process.exit(1);
24
+ }
25
+ // Rename source, stowmeta, stowcache
26
+ await renameFile(config.srcArtDir, assetPath, newId);
27
+ await renameFile(config.srcArtDir, `${assetPath}.stowmeta`, `${newId}.stowmeta`);
28
+ await renameFile(config.srcArtDir, `${assetPath}.stowcache`, `${newId}.stowcache`);
29
+ // For GLB containers, rename the .children cache directory
30
+ const meta = await readStowmeta(config.srcArtDir, newId);
31
+ if (meta && meta.type === 'glbContainer') {
32
+ await renameFile(config.srcArtDir, `${assetPath}.children`, `${newId}.children`);
33
+ }
34
+ console.log(`Renamed: ${assetPath} → ${newId}`);
35
+ }
36
+ // ─── Move ────────────────────────────────────────────────────────────────────
37
+ export async function moveAsset(projectDir, assetPath, targetFolder) {
38
+ const config = await readProjectConfig(projectDir);
39
+ const fileName = assetPath.split('/').pop() ?? assetPath;
40
+ const newId = targetFolder ? `${targetFolder}/${fileName}` : fileName;
41
+ if (newId === assetPath) {
42
+ console.log('Nothing to move — already in target folder.');
43
+ return;
44
+ }
45
+ // Verify source exists
46
+ const sourceData = await readFile(config.srcArtDir, assetPath);
47
+ if (!sourceData) {
48
+ console.error(`Asset not found: ${assetPath}`);
49
+ process.exit(1);
50
+ }
51
+ // Move source, stowmeta, stowcache
52
+ await renameFile(config.srcArtDir, assetPath, newId);
53
+ await renameFile(config.srcArtDir, `${assetPath}.stowmeta`, `${newId}.stowmeta`);
54
+ await renameFile(config.srcArtDir, `${assetPath}.stowcache`, `${newId}.stowcache`);
55
+ // For GLB containers, move the .children cache directory and update child references
56
+ const meta = await readStowmeta(config.srcArtDir, newId);
57
+ if (meta && meta.type === 'glbContainer') {
58
+ await renameFile(config.srcArtDir, `${assetPath}.children`, `${newId}.children`);
59
+ // Update texture references in material configs
60
+ const glbMeta = meta;
61
+ const oldPrefix = assetPath + '/';
62
+ const newPrefix = newId + '/';
63
+ let updated = false;
64
+ for (const child of glbMeta.children ?? []) {
65
+ if (child.materialConfig) {
66
+ for (const prop of child.materialConfig.properties) {
67
+ if (prop.textureAsset?.startsWith(oldPrefix)) {
68
+ prop.textureAsset = newPrefix + prop.textureAsset.slice(oldPrefix.length);
69
+ updated = true;
70
+ }
71
+ }
72
+ }
73
+ if (child.materialOverrides) {
74
+ for (const [k, v] of Object.entries(child.materialOverrides)) {
75
+ if (v?.startsWith(oldPrefix)) {
76
+ child.materialOverrides[k] = newPrefix + v.slice(oldPrefix.length);
77
+ updated = true;
78
+ }
79
+ }
80
+ }
81
+ }
82
+ if (updated) {
83
+ await writeStowmeta(config.srcArtDir, newId, glbMeta);
84
+ }
85
+ }
86
+ console.log(`Moved: ${assetPath} → ${newId}`);
87
+ }
88
+ // ─── Delete ──────────────────────────────────────────────────────────────────
89
+ export async function deleteAsset(projectDir, assetPath) {
90
+ const config = await readProjectConfig(projectDir);
91
+ // Verify source exists
92
+ const sourceData = await readFile(config.srcArtDir, assetPath);
93
+ if (!sourceData) {
94
+ console.error(`Asset not found: ${assetPath}`);
95
+ process.exit(1);
96
+ }
97
+ // For GLB containers, also delete the .children cache directory
98
+ const meta = await readStowmeta(config.srcArtDir, assetPath);
99
+ if (meta && meta.type === 'glbContainer') {
100
+ const childrenDir = path.join(config.srcArtDir, `${assetPath}.children`);
101
+ await fs.rm(childrenDir, { recursive: true, force: true }).catch(() => { });
102
+ }
103
+ // Delete source, stowmeta, stowcache
104
+ await deleteFile(config.srcArtDir, assetPath);
105
+ await deleteFile(config.srcArtDir, `${assetPath}.stowmeta`);
106
+ await deleteFile(config.srcArtDir, `${assetPath}.stowcache`);
107
+ console.log(`Deleted: ${assetPath}`);
108
+ }
109
+ // ─── Set stringId ────────────────────────────────────────────────────────────
110
+ export async function setStringId(projectDir, assetPath, newStringId) {
111
+ const config = await readProjectConfig(projectDir);
112
+ const meta = await readStowmeta(config.srcArtDir, assetPath);
113
+ if (!meta) {
114
+ console.error(`No .stowmeta found for: ${assetPath}`);
115
+ console.error('Run `stowkit build` or `stowkit scan` first to generate it.');
116
+ process.exit(1);
117
+ }
118
+ const oldStringId = meta.stringId;
119
+ meta.stringId = newStringId;
120
+ await writeStowmeta(config.srcArtDir, assetPath, meta);
121
+ console.log(`Updated stringId: "${oldStringId}" → "${newStringId}" (${assetPath})`);
122
+ }
package/dist/cli.js CHANGED
@@ -6,6 +6,8 @@ import { fullBuild, scanProject, showStatus } from './orchestrator.js';
6
6
  import { startServer } from './server.js';
7
7
  import { initProject } from './init.js';
8
8
  import { cleanupProject } from './cleanup.js';
9
+ import { createMaterial } from './create-material.js';
10
+ import { renameAsset, moveAsset, deleteAsset, setStringId } from './asset-commands.js';
9
11
  const args = process.argv.slice(2);
10
12
  const thisDir = path.dirname(fileURLToPath(import.meta.url));
11
13
  function printUsage() {
@@ -17,6 +19,11 @@ Usage:
17
19
  stowkit process [dir] Compress assets (respects cache)
18
20
  stowkit status [dir] Show project summary, stale asset count
19
21
  stowkit clean [dir] Delete orphaned .stowcache and .stowmeta files
22
+ stowkit create-material <path> Create a .stowmat material file
23
+ stowkit rename <path> <name> Rename an asset file
24
+ stowkit move <path> <folder> Move an asset to a different folder
25
+ stowkit delete <path> Delete an asset and its sidecar files
26
+ stowkit set-id <path> <id> Change an asset's stringId
20
27
  stowkit packer [dir] Open the packer GUI
21
28
  stowkit editor [dir] Open the level editor
22
29
  stowkit serve [dir] Start API server only (no GUI)
@@ -25,6 +32,7 @@ Options:
25
32
  --force Ignore cache and reprocess everything
26
33
  --verbose Detailed output
27
34
  --port Server port (default 3210)
35
+ --schema Material schema template: pbr (default), unlit, or custom name
28
36
  --help Show this help message
29
37
  `.trim());
30
38
  }
@@ -94,6 +102,54 @@ async function main() {
94
102
  case 'clean':
95
103
  await cleanupProject(projectDir, { verbose });
96
104
  break;
105
+ case 'create-material': {
106
+ // For create-material, the positional arg is the material path, not project dir
107
+ const matPath = args.find(a => !a.startsWith('-') && a !== command);
108
+ if (!matPath) {
109
+ console.error('Usage: stowkit create-material <path> [--schema pbr|unlit|<name>]');
110
+ process.exit(1);
111
+ }
112
+ const schemaIdx = args.indexOf('--schema');
113
+ const schema = schemaIdx >= 0 ? args[schemaIdx + 1] : undefined;
114
+ await createMaterial('.', matPath, { schema });
115
+ break;
116
+ }
117
+ case 'rename': {
118
+ const positional = args.filter(a => !a.startsWith('-') && a !== command);
119
+ if (positional.length < 2) {
120
+ console.error('Usage: stowkit rename <asset-path> <new-name>');
121
+ process.exit(1);
122
+ }
123
+ await renameAsset('.', positional[0], positional[1]);
124
+ break;
125
+ }
126
+ case 'move': {
127
+ const positional = args.filter(a => !a.startsWith('-') && a !== command);
128
+ if (positional.length < 2) {
129
+ console.error('Usage: stowkit move <asset-path> <target-folder>');
130
+ process.exit(1);
131
+ }
132
+ await moveAsset('.', positional[0], positional[1]);
133
+ break;
134
+ }
135
+ case 'delete': {
136
+ const assetPath = args.find(a => !a.startsWith('-') && a !== command);
137
+ if (!assetPath) {
138
+ console.error('Usage: stowkit delete <asset-path>');
139
+ process.exit(1);
140
+ }
141
+ await deleteAsset('.', assetPath);
142
+ break;
143
+ }
144
+ case 'set-id': {
145
+ const positional = args.filter(a => !a.startsWith('-') && a !== command);
146
+ if (positional.length < 2) {
147
+ console.error('Usage: stowkit set-id <asset-path> <new-string-id>');
148
+ process.exit(1);
149
+ }
150
+ await setStringId('.', positional[0], positional[1]);
151
+ break;
152
+ }
97
153
  case 'packer': {
98
154
  const packerDir = resolveAppDir('@series-inc/stowkit-packer-gui', 'stowkit-packer-gui');
99
155
  if (!packerDir) {
@@ -0,0 +1,3 @@
1
+ export declare function createMaterial(projectDir: string, relativePath: string, opts?: {
2
+ schema?: string;
3
+ }): Promise<void>;
@@ -0,0 +1,47 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as path from 'node:path';
3
+ import { readProjectConfig } from './node-fs.js';
4
+ const TEMPLATES = {
5
+ pbr: [
6
+ { fieldName: 'BaseColor', fieldType: 'texture', previewFlag: 'mainTex', value: [1, 1, 1, 1], textureAsset: null },
7
+ { fieldName: 'Normal', fieldType: 'texture', previewFlag: 'none', value: [0, 0, 1, 0], textureAsset: null },
8
+ { fieldName: 'Tint', fieldType: 'color', previewFlag: 'tint', value: [1, 1, 1, 1], textureAsset: null },
9
+ { fieldName: 'AlphaTest', fieldType: 'float', previewFlag: 'alphaTest', value: [0.5, 0, 0, 0], textureAsset: null },
10
+ ],
11
+ unlit: [
12
+ { fieldName: 'BaseColor', fieldType: 'texture', previewFlag: 'mainTex', value: [1, 1, 1, 1], textureAsset: null },
13
+ { fieldName: 'Tint', fieldType: 'color', previewFlag: 'tint', value: [1, 1, 1, 1], textureAsset: null },
14
+ ],
15
+ };
16
+ export async function createMaterial(projectDir, relativePath, opts) {
17
+ const config = await readProjectConfig(projectDir);
18
+ // Ensure .stowmat extension
19
+ if (!relativePath.endsWith('.stowmat')) {
20
+ relativePath += '.stowmat';
21
+ }
22
+ const fullPath = path.join(config.srcArtDir, relativePath);
23
+ // Don't overwrite existing
24
+ try {
25
+ await fs.access(fullPath);
26
+ console.error(`Material already exists: ${relativePath}`);
27
+ process.exit(1);
28
+ }
29
+ catch {
30
+ // Does not exist — good
31
+ }
32
+ const schemaName = opts?.schema ?? 'pbr';
33
+ const properties = TEMPLATES[schemaName] ?? [];
34
+ const mat = {
35
+ version: 1,
36
+ schemaName,
37
+ properties,
38
+ };
39
+ await fs.mkdir(path.dirname(fullPath), { recursive: true });
40
+ await fs.writeFile(fullPath, JSON.stringify(mat, null, 2) + '\n');
41
+ console.log(`Created material: ${relativePath}`);
42
+ console.log(` Schema: ${schemaName}`);
43
+ console.log(` Properties: ${properties.map(p => p.fieldName).join(', ') || '(none)'}`);
44
+ if (!TEMPLATES[schemaName]) {
45
+ console.log(` (Custom schema — no template properties. Edit the file to add properties.)`);
46
+ }
47
+ }
@@ -98,58 +98,8 @@ export async function fullBuild(projectDir, opts) {
98
98
  }
99
99
  assets.push(asset);
100
100
  assetsById.set(id, asset);
101
- // Expand GlbContainer children
102
- if (type === AssetType.GlbContainer && meta.type === 'glbContainer') {
103
- const glbMeta = meta;
104
- if (glbMeta.children && glbMeta.children.length > 0) {
105
- asset.status = 'ready'; // Container itself is always "ready"
106
- for (const child of glbMeta.children) {
107
- const childId = `${id}/${child.name}`;
108
- const baseName = child.name.replace(/\.[^.]+$/, '');
109
- // Read settings from inline child entry
110
- const { type: cType, settings: cSettings } = glbChildToAssetSettings(child);
111
- const childAsset = {
112
- id: childId,
113
- fileName: child.name,
114
- stringId: child.stringId || baseName,
115
- type: cType,
116
- status: 'pending',
117
- settings: cSettings,
118
- sourceSize: 0,
119
- processedSize: 0,
120
- parentId: id,
121
- locked: true,
122
- };
123
- // Check cache
124
- if (!force && child.cache) {
125
- const cached = await readCacheBlobs(config.srcArtDir, childId);
126
- if (cached) {
127
- for (const [key, data] of cached) {
128
- if (key === `${childId}:__metadata__`) {
129
- try {
130
- childAsset.metadata = JSON.parse(new TextDecoder().decode(data));
131
- }
132
- catch { /* skip */ }
133
- }
134
- else {
135
- BlobStore.setProcessed(key, data);
136
- }
137
- }
138
- childAsset.status = 'ready';
139
- childAsset.processedSize = BlobStore.getProcessed(childId)?.length ?? 0;
140
- if (verbose)
141
- console.log(` [cached] ${childId}`);
142
- }
143
- }
144
- assets.push(childAsset);
145
- assetsById.set(childId, childAsset);
146
- }
147
- }
148
- else {
149
- // No children manifest yet — need to parse GLB to discover them
150
- // This will be handled during processing
151
- }
152
- }
101
+ // GlbContainers are always extracted in section 2b (even if children exist in manifest)
102
+ // so that preserveHierarchy changes, re-exports, etc. are always reflected.
153
103
  }
154
104
  // Materials from .stowmat files
155
105
  for (const matFile of scan.matFiles) {
@@ -178,10 +128,9 @@ export async function fullBuild(projectDir, opts) {
178
128
  assets.push(asset);
179
129
  assetsById.set(id, asset);
180
130
  }
181
- // 2b. Process GLB containers that need extraction (no children yet)
182
- // Store extract results so mesh/animation children can be processed after encoder init
131
+ // 2b. Extract all GLB containers (always re-parse so preserveHierarchy etc. are reflected)
183
132
  const glbExtracts = new Map();
184
- const glbContainers = assets.filter(a => a.type === AssetType.GlbContainer && a.status === 'pending');
133
+ const glbContainers = assets.filter(a => a.type === AssetType.GlbContainer);
185
134
  for (const container of glbContainers) {
186
135
  try {
187
136
  const sourceData = await readFile(config.srcArtDir, container.id);
@@ -237,8 +186,6 @@ export async function fullBuild(projectDir, opts) {
237
186
  // Create child assets from inline entries
238
187
  for (const child of childrenManifest) {
239
188
  const childId = `${container.id}/${child.name}`;
240
- if (assetsById.has(childId))
241
- continue;
242
189
  const baseName = child.name.replace(/\.[^.]+$/, '');
243
190
  const { type: cType, settings: cSettings } = glbChildToAssetSettings(child);
244
191
  const childAsset = {
@@ -274,6 +221,27 @@ export async function fullBuild(projectDir, opts) {
274
221
  childAsset.processedSize = result.processedSize;
275
222
  }
276
223
  }
224
+ // Check cache for texture/mesh children (skip if --force)
225
+ if (childAsset.status === 'pending' && !force && child.cache) {
226
+ const cached = await readCacheBlobs(config.srcArtDir, childId);
227
+ if (cached) {
228
+ for (const [key, data] of cached) {
229
+ if (key === `${childId}:__metadata__`) {
230
+ try {
231
+ childAsset.metadata = JSON.parse(new TextDecoder().decode(data));
232
+ }
233
+ catch { /* skip */ }
234
+ }
235
+ else {
236
+ BlobStore.setProcessed(key, data);
237
+ }
238
+ }
239
+ childAsset.status = 'ready';
240
+ childAsset.processedSize = BlobStore.getProcessed(childId)?.length ?? 0;
241
+ if (verbose)
242
+ console.log(` [cached] ${childId}`);
243
+ }
244
+ }
277
245
  assets.push(childAsset);
278
246
  assetsById.set(childId, childAsset);
279
247
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@series-inc/stowkit-cli",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "stowkit": "./dist/cli.js"
@@ -17,7 +17,7 @@
17
17
  "dev": "tsc --watch"
18
18
  },
19
19
  "dependencies": {
20
- "@series-inc/stowkit-packer-gui": "^0.1.8",
20
+ "@series-inc/stowkit-packer-gui": "^0.1.9",
21
21
  "@series-inc/stowkit-editor": "^0.1.2",
22
22
  "draco3d": "^1.5.7",
23
23
  "fbx-parser": "^2.1.3",
package/skill.md CHANGED
@@ -12,7 +12,7 @@ StowKit is a game asset pipeline that compresses and packs assets into `.stow` b
12
12
 
13
13
  **Do not write `.stowmeta` files by hand.** Only edit an existing `.stowmeta` after it has been generated by the CLI (e.g. to change quality settings, pack assignment, or stringId). The same applies to GLB children — the `children` array is populated automatically on the first build.
14
14
 
15
- The only file you should manually create is `.stowmat` (material schema) files these are user-authored material definitions, not generated metadata.
15
+ To create materials, use `npx stowkit create-material <path>` — this scaffolds a `.stowmat` file with the right structure. You can also create `.stowmat` files manually if needed.
16
16
 
17
17
  ## Project Structure
18
18
 
@@ -42,6 +42,11 @@ npx stowkit scan [dir] # Detect new assets and generate .stowmeta d
42
42
  npx stowkit process [dir] # Compress assets (respects cache)
43
43
  npx stowkit status [dir] # Show project summary, stale asset count
44
44
  npx stowkit clean [dir] # Delete orphaned .stowcache and .stowmeta files
45
+ npx stowkit create-material <path> # Create a .stowmat material file (--schema pbr|unlit|<name>)
46
+ npx stowkit rename <path> <name> # Rename an asset file (preserves extension, updates sidecars)
47
+ npx stowkit move <path> <folder> # Move an asset to a different folder (updates GLB child refs)
48
+ npx stowkit delete <path> # Delete an asset and its .stowmeta/.stowcache files
49
+ npx stowkit set-id <path> <id> # Change an asset's stringId
45
50
  npx stowkit packer [dir] # Open the packer GUI in browser
46
51
  npx stowkit editor [dir] # Open the level editor in browser
47
52
  npx stowkit serve [dir] # Start API server only (no GUI)
@@ -53,6 +58,7 @@ All commands default to the current directory.
53
58
  - `--force` — Ignore cache and reprocess everything
54
59
  - `--verbose` / `-v` — Detailed output
55
60
  - `--port <number>` — Server port (default 3210)
61
+ - `--schema <name>` — Material schema template for `create-material` (default: `pbr`)
56
62
 
57
63
  ## Supported Asset Types
58
64
 
@@ -359,6 +365,18 @@ Add `*.stowcache` to `.gitignore`.
359
365
  5. To exclude a child from packing, set `"excluded": true` on that child entry
360
366
  6. To preserve the scene graph hierarchy in static meshes, set `"preserveHierarchy": true` on the container
361
367
 
368
+ ### Enabling preserveHierarchy from CLI
369
+
370
+ To enable hierarchy preservation on a GLB, edit its `.stowmeta` and set `"preserveHierarchy": true`, then rebuild:
371
+
372
+ ```bash
373
+ # After the first build has generated the .stowmeta, edit it:
374
+ # Set "preserveHierarchy": true in assets/models/hero.glb.stowmeta
375
+ npx stowkit build
376
+ ```
377
+
378
+ The CLI always re-extracts GLB containers on every build, so changes to `preserveHierarchy` take effect immediately — no `--force` needed.
379
+
362
380
  ### When to use preserveHierarchy
363
381
 
364
382
  - **Default (false):** All mesh geometry is baked to world space and flattened into a single mesh. Use this for simple props and environment pieces.
@@ -370,9 +388,14 @@ Add `*.stowcache` to `.gitignore`.
370
388
  - **Add a texture:** Place PNG/JPG into `assets/`, run `npx stowkit build`. The CLI auto-generates the `.stowmeta`. Do NOT create it yourself.
371
389
  - **Add audio:** Place WAV/MP3/OGG into `assets/`, run `npx stowkit build`. Same rule — never manually create `.stowmeta`.
372
390
  - **Add an FBX mesh:** Place FBX into `assets/`, run `npx stowkit build`.
391
+ - **Enable preserve hierarchy on a GLB:** Edit the `.stowmeta` for the GLB container, set `"preserveHierarchy": true`, then `npx stowkit build`
373
392
  - **Change compression quality:** Edit the **existing** `.stowmeta` file's quality/resize fields (after it was generated by a build/scan), then `npx stowkit build`
374
- - **Create a material:** Create a `.stowmat` JSON file in `assets/` (this is the one file type you DO create manually), then run `npx stowkit build`
393
+ - **Create a material:** Run `npx stowkit create-material materials/MyMat` (creates `assets/materials/MyMat.stowmat` with PBR template). Use `--schema unlit` for unlit materials, or `--schema <name>` for a custom schema. Then run `npx stowkit build`.
375
394
  - **Assign material to mesh:** Edit the mesh's **existing** `.stowmeta` to add `materialOverrides`
395
+ - **Rename an asset:** `npx stowkit rename textures/old_name.png new_name` (extension preserved automatically, sidecars renamed too)
396
+ - **Move an asset:** `npx stowkit move textures/hero.png characters` (moves to `characters/hero.png`, updates GLB child refs if container)
397
+ - **Delete an asset:** `npx stowkit delete textures/unused.png` (removes source + .stowmeta + .stowcache, cascades for GLB containers)
398
+ - **Change an asset's stringId:** `npx stowkit set-id textures/hero.png hero_diffuse`
376
399
  - **Check project health:** Run `npx stowkit status`
377
400
  - **Full rebuild:** `npx stowkit build --force`
378
401
  - **Clean orphaned files:** `npx stowkit clean`