1+ /*
2+ Toolbox Aid
3+ David Quesenberry
4+ 04/25/2026
5+
6+ sync-tool-hints-from-workspace-manager.mjs
7+
8+ How to run:
9+ 1) npm run sync:tool-hints
10+ - Sync toolHints into games/metadata/games.index.metadata.json from Workspace Manager game JSON
11+ 2) node ./scripts/sync-tool-hints-from-workspace-manager.mjs --dry-run
12+ - Validate and print what would change without writing
13+ */
14+
15+ import fs from "node:fs" ;
16+ import path from "node:path" ;
17+ import { getToolRegistry } from "../tools/toolRegistry.js" ;
18+
19+ const ROOT = process . cwd ( ) ;
20+ const METADATA_PATH = path . join ( ROOT , "games" , "metadata" , "games.index.metadata.json" ) ;
21+ const GAME_ASSET_CATALOG_FILENAME = "workspace.asset-catalog.json" ;
22+ const GAME_ASSET_CATALOG_SCHEMA = "html-js-gaming.game-asset-catalog" ;
23+ const GAME_ASSET_CATALOG_VERSION = 1 ;
24+ const GAME_TOOLS_MANIFEST_FILENAME = "tools.manifest.json" ;
25+ const GAME_TOOLS_MANIFEST_SCHEMA = "html-js-gaming.game-asset-manifest" ;
26+ const GAME_TOOLS_MANIFEST_VERSION = 1 ;
27+
28+ const KIND_TO_TOOL_HINTS = Object . freeze ( {
29+ palette : [ "palette-browser" ] ,
30+ skin : [ "skin-editor" ] ,
31+ sprite : [ "sprite-editor" ] ,
32+ tilemap : [ "tile-map-editor" ] ,
33+ parallax : [ "parallax-editor" ] ,
34+ vector : [ "vector-asset-studio" ] ,
35+ image : [ "asset-browser" ] ,
36+ audio : [ "asset-browser" ] ,
37+ video : [ "asset-browser" ] ,
38+ shader : [ "asset-browser" ] ,
39+ data : [ "asset-browser" ]
40+ } ) ;
41+
42+ function readJson ( filePath ) {
43+ return JSON . parse ( fs . readFileSync ( filePath , "utf8" ) ) ;
44+ }
45+
46+ function writeJson ( filePath , value ) {
47+ fs . writeFileSync ( filePath , `${ JSON . stringify ( value , null , 2 ) } \n` , "utf8" ) ;
48+ }
49+
50+ function normalizeText ( value ) {
51+ return typeof value === "string" ? value . trim ( ) : "" ;
52+ }
53+
54+ function normalizeToken ( value ) {
55+ return normalizeText ( value ) . toLowerCase ( ) ;
56+ }
57+
58+ function normalizeToolHints ( value ) {
59+ if ( ! Array . isArray ( value ) ) {
60+ return [ ] ;
61+ }
62+ const out = [ ] ;
63+ const seen = new Set ( ) ;
64+ for ( const entry of value ) {
65+ const token = normalizeToken ( entry ) ;
66+ if ( ! token || seen . has ( token ) ) {
67+ continue ;
68+ }
69+ seen . add ( token ) ;
70+ out . push ( token ) ;
71+ }
72+ return out ;
73+ }
74+
75+ function parseArgs ( argv ) {
76+ const args = {
77+ dryRun : false
78+ } ;
79+ for ( let i = 0 ; i < argv . length ; i += 1 ) {
80+ const value = argv [ i ] ;
81+ if ( value === "--dry-run" ) {
82+ args . dryRun = true ;
83+ continue ;
84+ }
85+ throw new Error ( `Unknown argument: ${ value } ` ) ;
86+ }
87+ return args ;
88+ }
89+
90+ function loadMetadata ( ) {
91+ if ( ! fs . existsSync ( METADATA_PATH ) ) {
92+ throw new Error ( `Missing metadata file: ${ path . relative ( ROOT , METADATA_PATH ) } ` ) ;
93+ }
94+ const metadata = readJson ( METADATA_PATH ) ;
95+ if ( ! metadata || typeof metadata !== "object" || ! Array . isArray ( metadata . games ) ) {
96+ throw new Error ( 'Metadata must contain a "games" array.' ) ;
97+ }
98+ return metadata ;
99+ }
100+
101+ function normalizeGameHref ( value ) {
102+ const href = normalizeText ( value ) . replace ( / \\ / g, "/" ) ;
103+ if ( ! href || ! href . startsWith ( "/games/" ) || href . includes ( ".." ) ) {
104+ return "" ;
105+ }
106+ return href ;
107+ }
108+
109+ function getGameDirFromHref ( gameHref ) {
110+ const href = normalizeGameHref ( gameHref ) ;
111+ if ( ! href ) {
112+ return "" ;
113+ }
114+ const stripped = href . endsWith ( "/index.html" )
115+ ? href . slice ( 0 , - "/index.html" . length )
116+ : href . endsWith ( "/" )
117+ ? href . slice ( 0 , - 1 )
118+ : href ;
119+ const relative = stripped . replace ( / ^ \/ + / , "" ) ;
120+ if ( ! relative ) {
121+ return "" ;
122+ }
123+ return path . join ( ROOT , relative ) ;
124+ }
125+
126+ function readWorkspaceAssetKinds ( gameDir ) {
127+ if ( ! gameDir ) {
128+ return [ ] ;
129+ }
130+ const catalogPath = path . join ( gameDir , "assets" , GAME_ASSET_CATALOG_FILENAME ) ;
131+ if ( ! fs . existsSync ( catalogPath ) ) {
132+ return [ ] ;
133+ }
134+ const source = readJson ( catalogPath ) ;
135+ const schema = normalizeText ( source ?. schema ) ;
136+ const version = Number ( source ?. version ) ;
137+ if ( schema !== GAME_ASSET_CATALOG_SCHEMA || version !== GAME_ASSET_CATALOG_VERSION ) {
138+ return [ ] ;
139+ }
140+ const assets = source ?. assets && typeof source . assets === "object" ? source . assets : { } ;
141+ const kinds = [ ] ;
142+ Object . values ( assets ) . forEach ( ( entry ) => {
143+ const kind = normalizeToken ( entry ?. kind ) ;
144+ if ( kind ) {
145+ kinds . push ( kind ) ;
146+ }
147+ } ) ;
148+ return [ ...new Set ( kinds ) ] ;
149+ }
150+
151+ function readToolHintsFromToolsManifest ( gameDir ) {
152+ if ( ! gameDir ) {
153+ return [ ] ;
154+ }
155+ const toolsManifestPath = path . join ( gameDir , "assets" , GAME_TOOLS_MANIFEST_FILENAME ) ;
156+ if ( ! fs . existsSync ( toolsManifestPath ) ) {
157+ return [ ] ;
158+ }
159+ const source = readJson ( toolsManifestPath ) ;
160+ const schema = normalizeText ( source ?. schema ) ;
161+ const version = Number ( source ?. version ) ;
162+ if ( schema !== GAME_TOOLS_MANIFEST_SCHEMA || version !== GAME_TOOLS_MANIFEST_VERSION ) {
163+ return [ ] ;
164+ }
165+ const domains = source ?. domains && typeof source . domains === "object" ? source . domains : { } ;
166+ const hints = [ ] ;
167+ let hasDomainRecords = false ;
168+ Object . values ( domains ) . forEach ( ( records ) => {
169+ if ( ! Array . isArray ( records ) || records . length === 0 ) {
170+ return ;
171+ }
172+ hasDomainRecords = true ;
173+ records . forEach ( ( record ) => {
174+ const sourceToolId = normalizeToken ( record ?. sourceToolId ) ;
175+ if ( sourceToolId ) {
176+ hints . push ( sourceToolId ) ;
177+ }
178+ } ) ;
179+ } ) ;
180+ if ( hasDomainRecords ) {
181+ hints . push ( "asset-pipeline-tool" ) ;
182+ }
183+ return normalizeToolHints ( hints ) ;
184+ }
185+
186+ function syncToolHints ( metadata ) {
187+ const knownToolIds = new Set ( getToolRegistry ( ) . map ( ( tool ) => normalizeToken ( tool ?. id ) ) . filter ( Boolean ) ) ;
188+ let updated = 0 ;
189+ const warnings = [ ] ;
190+
191+ for ( const game of metadata . games ) {
192+ const gameId = normalizeText ( game ?. id ) || "<unknown>" ;
193+ const gameDir = getGameDirFromHref ( game ?. href ) ;
194+ const existing = normalizeToolHints ( game ?. toolHints ) ;
195+ const derivedFromKinds = [ ] ;
196+
197+ readWorkspaceAssetKinds ( gameDir ) . forEach ( ( kind ) => {
198+ const mapped = KIND_TO_TOOL_HINTS [ kind ] || [ ] ;
199+ mapped . forEach ( ( toolId ) => derivedFromKinds . push ( toolId ) ) ;
200+ } ) ;
201+
202+ const derivedFromToolsManifest = readToolHintsFromToolsManifest ( gameDir ) ;
203+ const merged = normalizeToolHints ( [ ...existing , ...derivedFromKinds , ...derivedFromToolsManifest ] ) ;
204+ const invalid = merged . filter ( ( toolId ) => ! knownToolIds . has ( toolId ) ) ;
205+ if ( invalid . length > 0 ) {
206+ throw new Error ( `${ gameId } : unknown tool id(s): ${ invalid . join ( ", " ) } ` ) ;
207+ }
208+
209+ if ( ! gameDir ) {
210+ warnings . push ( `${ gameId } : skipped derivation (missing/invalid href)` ) ;
211+ } else if ( ! fs . existsSync ( path . join ( gameDir , "assets" , GAME_ASSET_CATALOG_FILENAME ) ) ) {
212+ warnings . push ( `${ gameId } : no ${ GAME_ASSET_CATALOG_FILENAME } ` ) ;
213+ }
214+
215+ if ( JSON . stringify ( existing ) !== JSON . stringify ( merged ) || ! Array . isArray ( game . toolHints ) ) {
216+ game . toolHints = merged ;
217+ updated += 1 ;
218+ }
219+ }
220+
221+ return { updated, warnings } ;
222+ }
223+
224+ function main ( ) {
225+ const args = parseArgs ( process . argv . slice ( 2 ) ) ;
226+ const metadata = loadMetadata ( ) ;
227+ const result = syncToolHints ( metadata ) ;
228+
229+ if ( ! args . dryRun ) {
230+ writeJson ( METADATA_PATH , metadata ) ;
231+ }
232+
233+ const warningText = result . warnings . length ? ` warnings=${ result . warnings . length } ` : "" ;
234+ console . log ( `OK updated=${ result . updated } ${ warningText } dryRun=${ args . dryRun ? "true" : "false" } ` ) ;
235+ if ( result . warnings . length ) {
236+ result . warnings . forEach ( ( warning ) => {
237+ console . log ( `WARN ${ warning } ` ) ;
238+ } ) ;
239+ }
240+ }
241+
242+ try {
243+ main ( ) ;
244+ } catch ( error ) {
245+ console . error ( `FAIL ${ error instanceof Error ? error . message : String ( error ) } ` ) ;
246+ process . exit ( 1 ) ;
247+ }
0 commit comments