11#!/usr/bin/env node
22/**
3- * fgos-kit — safe installer
3+ * fgos-kit — safe installer with incremental update (smart merge)
44 * Principles:
5- * - Do not modify anything without explicit user consent
6- * - Never delete without showing a detailed plan
7- * - Always generate an audit log (success and failures)
5+ * - Do not modify existing files without explicit user consent (overwrite)
6+ * - Add missing files/directories (incremental update)
7+ * - Always generate an audit log
88 */
99
1010import { Command } from "commander" ;
@@ -59,14 +59,43 @@ function download(url, outPath) {
5959}
6060
6161function ensureDir ( p ) {
62- fs . mkdirSync ( p , { recursive : true } ) ;
62+ if ( ! fs . existsSync ( p ) ) {
63+ fs . mkdirSync ( p , { recursive : true } ) ;
64+ return true ; // created
65+ }
66+ return false ; // existed
6367}
6468
6569function safeCopyDir ( src , dst ) {
66- // Node >=16 has cpSync
6770 fs . cpSync ( src , dst , { recursive : true } ) ;
6871}
6972
73+ // Smart Merge: walks source tree and copies ONLY if dest doesn't exist
74+ // Returns stats: { added: [], skipped: [] }
75+ function smartMerge ( src , dst , stats = { added : [ ] , skipped : [ ] } , rootDst = dst ) {
76+ const entries = fs . readdirSync ( src , { withFileTypes : true } ) ;
77+
78+ ensureDir ( dst ) ;
79+
80+ for ( const entry of entries ) {
81+ const srcPath = path . join ( src , entry . name ) ;
82+ const dstPath = path . join ( dst , entry . name ) ;
83+ const relPath = path . relative ( rootDst , dstPath ) ;
84+
85+ if ( entry . isDirectory ( ) ) {
86+ smartMerge ( srcPath , dstPath , stats , rootDst ) ;
87+ } else {
88+ if ( fs . existsSync ( dstPath ) ) {
89+ stats . skipped . push ( relPath ) ;
90+ } else {
91+ fs . copyFileSync ( srcPath , dstPath ) ;
92+ stats . added . push ( relPath ) ;
93+ }
94+ }
95+ }
96+ return stats ;
97+ }
98+
7099function writeAuditLog ( targetDir , logObj ) {
71100 const logDir = path . join ( targetDir , ".agent" , "_audit" ) ;
72101 ensureDir ( logDir ) ;
@@ -101,16 +130,16 @@ function writeAuditLog(targetDir, logObj) {
101130program
102131 . name ( "fgos-kit" )
103132 . description ( "Safe installer for Finance Data Governance OS (.agent kit)" )
104- . version ( "0.2 .0" ) ;
133+ . version ( "0.3 .0" ) ;
105134
106135program
107136 . command ( "init" )
108- . description ( "Initialize .agent/ with governance agents, skills, workflows and rules (safe-by-default )" )
137+ . description ( "Initialize or update .agent/ (safe incremental merge )" )
109138 . option ( "--path <dir>" , "target directory" , "." )
110139 . option ( "--branch <name>" , "branch to download from" , DEFAULT_BRANCH )
111- . option ( "--yes" , "skip interactive prompts (still creates backup before overwrite) " , false )
140+ . option ( "--yes" , "skip interactive prompts" , false )
112141 . option ( "--dry-run" , "show plan and exit without changes" , false )
113- . option ( "--overwrite" , "allow overwriting existing .agent content (creates backup first )" , false )
142+ . option ( "--overwrite" , "force overwrite existing files (creates backup)" , false )
114143 . action ( async ( opts ) => {
115144 const target = path . resolve ( opts . path ) ;
116145 const agentDir = path . join ( target , ".agent" ) ;
@@ -129,104 +158,100 @@ program
129158 } ;
130159
131160 try {
132- // 1) Context check (non-destructive)
133- if ( ! fs . existsSync ( target ) ) {
134- throw new Error ( `Target path does not exist: ${ target } ` ) ;
135- }
161+ if ( ! fs . existsSync ( target ) ) throw new Error ( `Target path does not exist: ${ target } ` ) ;
136162
137163 const agentExists = fs . existsSync ( agentDir ) ;
138164 logObj . plan . push ( `Detect target: ${ target } ` ) ;
139165 logObj . plan . push ( `.agent exists: ${ agentExists } ` ) ;
140166
141- // 2) Decide what would happen
142- if ( ! agentExists ) {
143- logObj . plan . push ( `Create .agent/ directory` ) ;
144- } else {
145- logObj . plan . push ( `Existing .agent/ found` ) ;
146- logObj . plan . push ( `No deletion will happen unless --overwrite is provided and user confirms` ) ;
167+ let mode = "CREATE" ;
168+ if ( agentExists ) {
169+ if ( opts . overwrite ) mode = "OVERWRITE" ;
170+ else mode = "MERGE" ;
147171 }
148172
149- logObj . plan . push ( `Download kit from GitHub tarball` ) ;
150- logObj . plan . push ( `Copy: agents/, skills/, workflows/ (if present), rules/ (if present) into .agent/` ) ;
173+ logObj . plan . push ( `Operation Mode: ${ mode } ` ) ;
174+ if ( mode === "OVERWRITE" ) logObj . plan . push ( `Backup existing .agent -> Wipe -> Reinstall` ) ;
175+ if ( mode === "MERGE" ) logObj . plan . push ( `Incremental update: Add missing files, SKIP existing files` ) ;
151176
152- // 3) Show plan + require consent
153- console . log ( "\n=== PLAN (no changes yet) ===" ) ;
177+ // Show plan
178+ console . log ( "\n=== PLAN ===" ) ;
154179 for ( const p of logObj . plan ) console . log ( " -" , p ) ;
155180
156181 if ( opts . dryRun ) {
157- console . log ( "\n✅ Dry-run complete. No files were changed." ) ;
158- return ;
159- }
160-
161- if ( agentExists && ! opts . overwrite ) {
162- console . log ( "\n⚠️ .agent/ already exists." ) ;
163- console . log ( "To proceed safely, re-run with: --overwrite" ) ;
164- console . log ( "Nothing was changed." ) ;
182+ console . log ( "\n✅ Dry-run complete." ) ;
165183 return ;
166184 }
167185
168- // If overwrite, ask confirmation (unless --yes)
186+ // Confirmation
169187 if ( ! opts . yes ) {
170- const ok = await askYesNo ( " \nProceed with installation ? (y/N): " ) ;
188+ const ok = await askYesNo ( ` \nProceed with ${ mode } ? (y/N): ` ) ;
171189 if ( ! ok ) {
172- console . log ( "Cancelled. No changes made. " ) ;
190+ console . log ( "Cancelled." ) ;
173191 return ;
174192 }
175193 }
176194
177- // 4) Download & extract (still not touching target until ready)
195+ // Download
178196 const tmp = fs . mkdtempSync ( path . join ( os . tmpdir ( ) , "fgos-" ) ) ;
179197 const tarPath = path . join ( tmp , "repo.tar.gz" ) ;
180198 const url = `https://codeload.github.com/${ REPO } /tar.gz/refs/heads/${ opts . branch } ` ;
181199
182200 console . log ( "\n📦 Downloading kit..." ) ;
183201 await download ( url , tarPath ) ;
184-
185- console . log ( "📂 Extracting..." ) ;
186202 execSync ( `tar -xzf "${ tarPath } " -C "${ tmp } "` ) ;
187-
188203 const extracted = fs . readdirSync ( tmp ) . find ( ( d ) => d . startsWith ( "finance-data-governance-os-" ) ) ;
189- if ( ! extracted ) throw new Error ( "Could not find extracted repository directory" ) ;
190204 const repoRoot = path . join ( tmp , extracted ) ;
191205
192- // 5) Backup if overwriting
193- if ( agentExists ) {
206+ // Execution
207+ ensureDir ( agentDir ) ;
208+
209+ if ( mode === "OVERWRITE" ) {
194210 const backupDir = path . join ( target , `.agent.bak-${ runId } ` ) ;
195211 console . log ( `🧷 Creating backup: ${ backupDir } ` ) ;
196212 safeCopyDir ( agentDir , backupDir ) ;
197213 logObj . executed . push ( `Backup created: ${ backupDir } ` ) ;
198- }
199214
200- // 6) Apply changes (scoped to .agent only)
201- ensureDir ( agentDir ) ;
215+ // Wipe subdirs to ensure clean slate, but keep .agent root (for audit logs)
216+ const dirsToClear = [ "agents" , "skills" , "workflows" , "rules" ] ;
217+ for ( const d of dirsToClear ) fs . rmSync ( path . join ( agentDir , d ) , { recursive : true , force : true } ) ;
218+ }
202219
203220 const dirsToCopy = [ "agents" , "skills" , "workflows" , "rules" ] ;
221+ let totalAdded = 0 ;
222+ let totalSkipped = 0 ;
223+
204224 for ( const name of dirsToCopy ) {
205225 const src = path . join ( repoRoot , name ) ;
206226 const dst = path . join ( agentDir , name ) ;
207227
208228 if ( fs . existsSync ( src ) ) {
209- // if overwriting, remove target subdir (not whole .agent)
210- fs . rmSync ( dst , { recursive : true , force : true } ) ;
211- safeCopyDir ( src , dst ) ;
212- console . log ( ` ✓ ${ name } /` ) ;
213- logObj . executed . push ( `Copied ${ name } / to .agent/${ name } /` ) ;
229+ if ( mode === "OVERWRITE" ) {
230+ safeCopyDir ( src , dst ) ;
231+ console . log ( ` ✓ ${ name } / (overwritten)` ) ;
232+ logObj . executed . push ( `Overwritten ${ name } /` ) ;
233+ } else {
234+ // MERGE logic
235+ const stats = smartMerge ( src , dst , undefined , agentDir ) ;
236+ console . log ( ` ✓ ${ name } / (added: ${ stats . added . length } , skipped: ${ stats . skipped . length } )` ) ;
237+ logObj . executed . push ( `Merged ${ name } /: ${ stats . added . length } new, ${ stats . skipped . length } skipped` ) ;
238+ totalAdded += stats . added . length ;
239+ totalSkipped += stats . skipped . length ;
240+ }
214241 }
215242 }
216243
217- // 7) Write audit logs
218- writeAuditLog ( target , logObj ) ;
219-
220- console . log ( "\n✅ Installed safely." ) ;
221- console . log ( `Audit log saved at: ${ path . join ( target , ".agent" , "_audit" ) } ` ) ;
244+ if ( mode === "MERGE" ) {
245+ console . log ( `\nStats: ${ totalAdded } files added, ${ totalSkipped } files preserved.` ) ;
246+ }
222247
223- // cleanup
248+ writeAuditLog ( target , logObj ) ;
249+ console . log ( "\n✅ Done." ) ;
224250 fs . rmSync ( tmp , { recursive : true , force : true } ) ;
251+
225252 } catch ( err ) {
226- logObj . errors . push ( String ( err ?. stack || err ?. message || err ) ) ;
227- try {
228- writeAuditLog ( path . resolve ( opts . path ) , logObj ) ;
229- } catch { }
253+ logObj . errors . push ( String ( err ?. stack || err ?. message ) ) ;
254+ try { writeAuditLog ( path . resolve ( opts . path ) , logObj ) ; } catch { }
230255 console . error ( "\n❌ Error:" , err . message ) ;
231256 process . exit ( 1 ) ;
232257 }
0 commit comments