magic-spec 1.4.1 → 1.4.3
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/CHANGELOG.md +13 -1
- package/installers/config.json +8 -0
- package/installers/node/index.js +163 -89
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,7 +5,19 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
-
## [1.4.
|
|
8
|
+
## [1.4.3] - 2026-03-01
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Smart Adapter Updates**: Both Python and Node.js installers now seamlessly verify and update adapter `.mdc`/`.toml` wrapper files during `--update`.
|
|
13
|
+
- **Smart Update Conflict Resolution**: When users manually modify `.cursor/rules/*` wrapper files in their project, the `--update` command now detects the modifications. Users can intelligently choose to skip updates for specific conflicting adapter files, preserving their changes while updating the core `.magic` engine logic.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **Updater Integrity**: Modified files that are bypassed during an update via `[s] Skip modified files` now persist their original hashes so they don't get silently overwritten in subsequent updates.
|
|
18
|
+
- **Testing Logic**: Fixed an issue in the `run_tests.py` exhaustive test suite section where failures during adapter testing cycles were mistakenly swallowed, reporting a false "All tests completed successfully".
|
|
19
|
+
|
|
20
|
+
## [1.4.3] - 2026-03-01
|
|
9
21
|
|
|
10
22
|
### Fixed
|
|
11
23
|
|
package/installers/config.json
CHANGED
|
@@ -65,5 +65,13 @@
|
|
|
65
65
|
"CHANGELOG.md"
|
|
66
66
|
],
|
|
67
67
|
"docsDir": "docs"
|
|
68
|
+
},
|
|
69
|
+
"tests": {
|
|
70
|
+
"sandboxDir": "installers/tests/sandbox",
|
|
71
|
+
"adaptersJson": "installers/adapters.json",
|
|
72
|
+
"pythonInstaller": "installers/python/magic_spec/__main__.py",
|
|
73
|
+
"nodeInstaller": "installers/node/index.js",
|
|
74
|
+
"testDir": "installers/tests",
|
|
75
|
+
"testPattern": "test_*.py"
|
|
68
76
|
}
|
|
69
77
|
}
|
package/installers/node/index.js
CHANGED
|
@@ -116,6 +116,7 @@ const isInfo = args.includes('info');
|
|
|
116
116
|
const isListEnvs = args.includes('--list-envs');
|
|
117
117
|
const isEject = args.includes('--eject');
|
|
118
118
|
const isFallbackMain = args.includes('--fallback-main');
|
|
119
|
+
const isLocal = args.includes('--local');
|
|
119
120
|
const autoAccept = args.includes('--yes') || args.includes('-y');
|
|
120
121
|
|
|
121
122
|
function parseCsvValues(raw) {
|
|
@@ -189,14 +190,15 @@ function convertToMdc(content, description) {
|
|
|
189
190
|
return `---\ndescription: ${description || ''}\nglobs: \n---\n${content}`;
|
|
190
191
|
}
|
|
191
192
|
|
|
192
|
-
function installAdapter(sourceRoot, env, adapters) {
|
|
193
|
+
function installAdapter(sourceRoot, env, adapters, conflictsToSkip = []) {
|
|
194
|
+
const installedChecksums = {};
|
|
193
195
|
const adapter = adapters[env];
|
|
194
196
|
if (!adapter) {
|
|
195
197
|
console.warn(`⚠️ Unknown --env value: "${env}".`);
|
|
196
198
|
console.warn(` Valid values: ${Object.keys(adapters).join(', ')}`);
|
|
197
199
|
console.warn(` Falling back to default ${AGENT_DIR}/`);
|
|
198
200
|
copyDir(path.join(sourceRoot, AGENT_DIR), path.join(cwd, AGENT_DIR));
|
|
199
|
-
return;
|
|
201
|
+
return installedChecksums;
|
|
200
202
|
}
|
|
201
203
|
|
|
202
204
|
const srcDir = path.join(sourceRoot, AGENT_DIR, WORKFLOWS_DIR);
|
|
@@ -204,9 +206,12 @@ function installAdapter(sourceRoot, env, adapters) {
|
|
|
204
206
|
|
|
205
207
|
if (!fs.existsSync(srcDir)) {
|
|
206
208
|
console.warn(`⚠️ Source ${AGENT_DIR}/${WORKFLOWS_DIR}/ not found.`);
|
|
207
|
-
return;
|
|
209
|
+
return installedChecksums;
|
|
208
210
|
}
|
|
209
211
|
|
|
212
|
+
if (adapter.marker) {
|
|
213
|
+
fs.mkdirSync(path.join(cwd, adapter.marker), { recursive: true });
|
|
214
|
+
}
|
|
210
215
|
fs.mkdirSync(destDir, { recursive: true });
|
|
211
216
|
|
|
212
217
|
for (const wfName of WORKFLOWS) {
|
|
@@ -221,22 +226,56 @@ function installAdapter(sourceRoot, env, adapters) {
|
|
|
221
226
|
destName = destName.slice(removePrefix.length);
|
|
222
227
|
}
|
|
223
228
|
}
|
|
229
|
+
|
|
224
230
|
const destFile = path.join(destDir, destName);
|
|
231
|
+
const relTarget = path.relative(cwd, destFile).replace(/\\/g, '/');
|
|
232
|
+
|
|
233
|
+
if (conflictsToSkip.includes(relTarget)) continue;
|
|
234
|
+
|
|
235
|
+
const content = fs.readFileSync(srcFile, 'utf8');
|
|
236
|
+
let finalContent = content;
|
|
225
237
|
|
|
226
238
|
if (adapter.format === 'toml' || adapter.ext === '.toml') {
|
|
227
|
-
|
|
228
|
-
const description = `Magic SDD Workflow: ${destName}`;
|
|
229
|
-
fs.writeFileSync(destFile, convertToToml(content, description), 'utf8');
|
|
239
|
+
finalContent = convertToToml(content, `Magic SDD Workflow: ${destName}`);
|
|
230
240
|
} else if (adapter.format === 'mdc' || adapter.ext === '.mdc') {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
241
|
+
finalContent = convertToMdc(content, `Magic SDD Workflow: ${destName}`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
fs.writeFileSync(destFile, finalContent, 'utf8');
|
|
245
|
+
installedChecksums[relTarget] = crypto.createHash('sha256').update(finalContent).digest('hex');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Copy other files in .agent if any
|
|
249
|
+
const srcEng = path.join(sourceRoot, AGENT_DIR);
|
|
250
|
+
if (fs.existsSync(srcEng)) {
|
|
251
|
+
const items = fs.readdirSync(srcEng, { withFileTypes: true });
|
|
252
|
+
for (const item of items) {
|
|
253
|
+
if (item.name === WORKFLOWS_DIR) continue;
|
|
254
|
+
|
|
255
|
+
const srcItem = path.join(srcEng, item.name);
|
|
256
|
+
const destItem = path.join(cwd, AGENT_DIR, item.name);
|
|
257
|
+
const relTarget = path.relative(cwd, destItem).replace(/\\/g, '/');
|
|
258
|
+
|
|
259
|
+
if (item.isDirectory()) {
|
|
260
|
+
copyDir(srcItem, destItem);
|
|
261
|
+
const dirHash = getDirectoryChecksums(destItem, cwd);
|
|
262
|
+
Object.assign(installedChecksums, dirHash);
|
|
263
|
+
} else {
|
|
264
|
+
if (conflictsToSkip.includes(relTarget)) continue;
|
|
265
|
+
fs.mkdirSync(path.join(cwd, AGENT_DIR), { recursive: true });
|
|
266
|
+
fs.copyFileSync(srcItem, destItem);
|
|
267
|
+
installedChecksums[relTarget] = getFileChecksum(destItem);
|
|
268
|
+
}
|
|
236
269
|
}
|
|
237
270
|
}
|
|
238
271
|
|
|
239
|
-
|
|
272
|
+
if (conflictsToSkip.length > 0 || isUpdate) {
|
|
273
|
+
console.log(`✅ Adapter updated: ${env} → ${adapter.dest}/ (${adapter.ext})`);
|
|
274
|
+
} else {
|
|
275
|
+
console.log(`✅ Adapter installed: ${env} → ${adapter.dest}/ (${adapter.ext})`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return installedChecksums;
|
|
240
279
|
}
|
|
241
280
|
|
|
242
281
|
function runDoctor() {
|
|
@@ -331,16 +370,20 @@ function runInfo() {
|
|
|
331
370
|
}
|
|
332
371
|
console.log(`Installed version : ${installedVersion} (.magic/.version)`);
|
|
333
372
|
|
|
334
|
-
const
|
|
335
|
-
let
|
|
336
|
-
if (fs.existsSync(
|
|
373
|
+
const ADAPTERS_PATH = path.join(__dirname, '..', 'adapters.json');
|
|
374
|
+
let activeEnvs = [];
|
|
375
|
+
if (fs.existsSync(ADAPTERS_PATH)) {
|
|
337
376
|
try {
|
|
338
|
-
const
|
|
339
|
-
|
|
377
|
+
const adapters = JSON.parse(fs.readFileSync(ADAPTERS_PATH, 'utf8'));
|
|
378
|
+
activeEnvs = detectEnvironments(adapters);
|
|
340
379
|
} catch (e) { }
|
|
341
380
|
}
|
|
342
|
-
console.log(`Active env : ${activeEnv}`);
|
|
343
381
|
|
|
382
|
+
if (activeEnvs.length === 0) {
|
|
383
|
+
console.log(`Active env : default (${AGENT_DIR}/)`);
|
|
384
|
+
} else {
|
|
385
|
+
console.log(`Active envs : ${activeEnvs.join(', ')}`);
|
|
386
|
+
}
|
|
344
387
|
const enginePresent = fs.existsSync(path.join(cwd, ENGINE_DIR));
|
|
345
388
|
console.log(`Engine : ${ENGINE_DIR}/ ${enginePresent ? '✅ present' : '❌ missing'}`);
|
|
346
389
|
|
|
@@ -431,18 +474,17 @@ async function runEject() {
|
|
|
431
474
|
}
|
|
432
475
|
}
|
|
433
476
|
|
|
434
|
-
function
|
|
477
|
+
function detectEnvironments(adapters) {
|
|
478
|
+
const detected = [];
|
|
435
479
|
for (const env in adapters) {
|
|
436
480
|
const marker = adapters[env].marker;
|
|
437
|
-
if (marker && fs.existsSync(path.join(cwd, marker)))
|
|
481
|
+
if (marker && fs.existsSync(path.join(cwd, marker))) {
|
|
482
|
+
detected.push(env);
|
|
483
|
+
}
|
|
438
484
|
}
|
|
439
|
-
return
|
|
485
|
+
return detected;
|
|
440
486
|
}
|
|
441
487
|
|
|
442
|
-
function saveMagicRc(config) {
|
|
443
|
-
const magicrcFile = path.join(cwd, '.magicrc');
|
|
444
|
-
fs.writeFileSync(magicrcFile, JSON.stringify(config, null, 2), 'utf8');
|
|
445
|
-
}
|
|
446
488
|
|
|
447
489
|
function getFileChecksum(filePath) {
|
|
448
490
|
if (!fs.existsSync(filePath)) return null;
|
|
@@ -482,8 +524,15 @@ async function handleConflicts(cwd) {
|
|
|
482
524
|
|
|
483
525
|
const conflicts = [];
|
|
484
526
|
for (const [relPath, storedHash] of Object.entries(storedChecksums)) {
|
|
485
|
-
|
|
486
|
-
|
|
527
|
+
if (relPath === '.checksums' || relPath === '.version') continue;
|
|
528
|
+
|
|
529
|
+
// Backward compatibility: try .magic first, then project root
|
|
530
|
+
let localPath = path.join(cwd, '.magic', relPath);
|
|
531
|
+
if (!fs.existsSync(localPath)) {
|
|
532
|
+
localPath = path.join(cwd, relPath);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (fs.existsSync(localPath) && fs.statSync(localPath).isFile()) {
|
|
487
536
|
const currentHash = getFileChecksum(localPath);
|
|
488
537
|
if (currentHash !== storedHash) {
|
|
489
538
|
conflicts.push(relPath);
|
|
@@ -493,7 +542,7 @@ async function handleConflicts(cwd) {
|
|
|
493
542
|
|
|
494
543
|
if (conflicts.length === 0) return;
|
|
495
544
|
|
|
496
|
-
console.log(`\n⚠️ Local changes detected in ${conflicts.length} file(s)
|
|
545
|
+
console.log(`\n⚠️ Local changes detected in ${conflicts.length} file(s):`);
|
|
497
546
|
conflicts.slice(0, 5).forEach(f => console.log(` - ${f}`));
|
|
498
547
|
if (conflicts.length > 5) console.log(` ... and ${conflicts.length - 5} more.`);
|
|
499
548
|
|
|
@@ -661,13 +710,14 @@ async function main() {
|
|
|
661
710
|
console.log("\nOptions:");
|
|
662
711
|
console.log(" --env <adapter> Specify environment adapter");
|
|
663
712
|
console.log(" --<adapter> Shortcut for --env <adapter> (e.g. --cursor)");
|
|
664
|
-
console.log(" --update Update engine files
|
|
713
|
+
console.log(" --update Update engine and adapter files");
|
|
714
|
+
console.log(" --local Use local project files instead of GitHub");
|
|
665
715
|
console.log(" --fallback-main Pull payload from main branch");
|
|
666
716
|
console.log(" --yes, -y Auto-accept prompts");
|
|
667
717
|
process.exit(0);
|
|
668
718
|
}
|
|
669
719
|
|
|
670
|
-
console.log(isUpdate ? '🪄 Updating magic-spec
|
|
720
|
+
console.log(isUpdate ? '🪄 Updating magic-spec...' : '🪄 Initializing magic-spec...');
|
|
671
721
|
|
|
672
722
|
if (isUpdate) {
|
|
673
723
|
createBackup();
|
|
@@ -676,18 +726,16 @@ async function main() {
|
|
|
676
726
|
const versionToFetch = isFallbackMain ? 'main' : version;
|
|
677
727
|
let sourceRoot = null;
|
|
678
728
|
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
try {
|
|
684
|
-
magicrc = JSON.parse(fs.readFileSync(magicrcFile, 'utf8'));
|
|
685
|
-
} catch (e) { }
|
|
729
|
+
if (isLocal) {
|
|
730
|
+
// In local mode, source root is the parent of 'installers' folder
|
|
731
|
+
sourceRoot = path.resolve(__dirname, '..', '..');
|
|
732
|
+
console.log(`🏠 Using local project files from: ${sourceRoot}`);
|
|
686
733
|
}
|
|
687
734
|
|
|
688
735
|
try {
|
|
689
|
-
|
|
690
|
-
|
|
736
|
+
if (!isLocal) {
|
|
737
|
+
sourceRoot = await downloadPayload(versionToFetch);
|
|
738
|
+
}
|
|
691
739
|
let ADAPTERS = {};
|
|
692
740
|
try {
|
|
693
741
|
ADAPTERS = JSON.parse(fs.readFileSync(path.join(sourceRoot, 'installers', 'adapters.json'), 'utf8'));
|
|
@@ -706,25 +754,35 @@ async function main() {
|
|
|
706
754
|
}
|
|
707
755
|
}
|
|
708
756
|
|
|
709
|
-
let selectedEnvResolved = null;
|
|
710
757
|
if (envValues.length > 0) {
|
|
711
758
|
selectedEnvResolved = envValues[0];
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
if (envValues.length === 0) {
|
|
762
|
+
const detected = detectEnvironments(ADAPTERS);
|
|
763
|
+
if (detected.length > 0) {
|
|
764
|
+
if (isUpdate) {
|
|
765
|
+
for (const env of detected) {
|
|
766
|
+
if (!envValues.includes(env)) envValues.push(env);
|
|
767
|
+
}
|
|
768
|
+
selectedEnvResolved = envValues[0];
|
|
769
|
+
} else {
|
|
770
|
+
if (detected.length === 1) {
|
|
771
|
+
const env = detected[0];
|
|
772
|
+
const adapterName = ADAPTERS[env].description || env;
|
|
773
|
+
console.log(`\n💡 Detected ${adapterName} (${ADAPTERS[env].marker}/ directory found).`);
|
|
774
|
+
let shouldAdopt = autoAccept;
|
|
775
|
+
if (!shouldAdopt) {
|
|
776
|
+
const answer = await askQuestion(` Install ${env} adapter instead of default? (y/N): `);
|
|
777
|
+
shouldAdopt = answer.toLowerCase() === 'y';
|
|
778
|
+
}
|
|
779
|
+
if (shouldAdopt) {
|
|
780
|
+
selectedEnvResolved = env;
|
|
781
|
+
}
|
|
782
|
+
} else {
|
|
783
|
+
console.log(`\n💡 Multiple environments detected: ${detected.join(', ')}`);
|
|
784
|
+
console.log(` Use --env <name> or --<name> to target specific one.`);
|
|
785
|
+
}
|
|
728
786
|
}
|
|
729
787
|
}
|
|
730
788
|
}
|
|
@@ -757,36 +815,45 @@ async function main() {
|
|
|
757
815
|
}
|
|
758
816
|
|
|
759
817
|
// 2. Adapters
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
818
|
+
let adapterChecksums = {};
|
|
819
|
+
if (envValues.length > 0) {
|
|
820
|
+
for (const env of envValues) {
|
|
821
|
+
Object.assign(adapterChecksums, installAdapter(sourceRoot, env, ADAPTERS, conflictsToSkip));
|
|
822
|
+
}
|
|
823
|
+
} else if (selectedEnvResolved) {
|
|
824
|
+
Object.assign(adapterChecksums, installAdapter(sourceRoot, selectedEnvResolved, ADAPTERS, conflictsToSkip));
|
|
825
|
+
} else if (!isUpdate) {
|
|
826
|
+
// Default install
|
|
827
|
+
const srcEng = path.join(sourceRoot, AGENT_DIR);
|
|
828
|
+
const destEng = path.join(cwd, AGENT_DIR);
|
|
829
|
+
fs.mkdirSync(destEng, { recursive: true });
|
|
830
|
+
fs.mkdirSync(path.join(destEng, WORKFLOWS_DIR), { recursive: true });
|
|
831
|
+
|
|
832
|
+
for (const wfName of WORKFLOWS) {
|
|
833
|
+
const file = wfName + DEFAULT_EXT;
|
|
834
|
+
const srcWf = path.join(srcEng, WORKFLOWS_DIR, file);
|
|
835
|
+
if (fs.existsSync(srcWf)) {
|
|
836
|
+
const destWf = path.join(destEng, WORKFLOWS_DIR, file);
|
|
837
|
+
fs.copyFileSync(srcWf, destWf);
|
|
838
|
+
const relTarget = path.relative(cwd, destWf).replace(/\\/g, '/');
|
|
839
|
+
adapterChecksums[relTarget] = getFileChecksum(destWf);
|
|
780
840
|
}
|
|
841
|
+
}
|
|
781
842
|
|
|
782
|
-
|
|
843
|
+
// Copy other files in .agent if any (not workflows subfolder which we handled selectively)
|
|
844
|
+
if (fs.existsSync(srcEng)) {
|
|
783
845
|
const items = fs.readdirSync(srcEng, { withFileTypes: true });
|
|
784
846
|
for (const item of items) {
|
|
785
847
|
if (item.name === WORKFLOWS_DIR) continue;
|
|
848
|
+
const srcItem = path.join(srcEng, item.name);
|
|
849
|
+
const destItem = path.join(destEng, item.name);
|
|
786
850
|
if (item.isDirectory()) {
|
|
787
|
-
copyDir(
|
|
851
|
+
copyDir(srcItem, destItem);
|
|
852
|
+
Object.assign(adapterChecksums, getDirectoryChecksums(destItem, cwd));
|
|
788
853
|
} else {
|
|
789
|
-
fs.copyFileSync(
|
|
854
|
+
fs.copyFileSync(srcItem, destItem);
|
|
855
|
+
const relTarget = path.relative(cwd, destItem).replace(/\\/g, '/');
|
|
856
|
+
adapterChecksums[relTarget] = getFileChecksum(destItem);
|
|
790
857
|
}
|
|
791
858
|
}
|
|
792
859
|
}
|
|
@@ -842,20 +909,27 @@ async function main() {
|
|
|
842
909
|
console.warn(`⚠️ Failed to write .magic/.version: ${vErr.message}`);
|
|
843
910
|
}
|
|
844
911
|
|
|
845
|
-
// 5. Update .magicrc - [T-2C02]
|
|
846
|
-
try {
|
|
847
|
-
const newConfig = {
|
|
848
|
-
env: selectedEnvResolved || magicrc.env || 'default',
|
|
849
|
-
version: version
|
|
850
|
-
};
|
|
851
|
-
saveMagicRc(newConfig);
|
|
852
|
-
} catch (rcErr) {
|
|
853
|
-
console.warn(`⚠️ Failed to update .magicrc: ${rcErr.message}`);
|
|
854
|
-
}
|
|
855
912
|
|
|
856
913
|
// 6. Save checksums - [T-2C03]
|
|
857
914
|
try {
|
|
858
915
|
const currentChecksums = getDirectoryChecksums(path.join(cwd, '.magic'));
|
|
916
|
+
|
|
917
|
+
if (isUpdate) {
|
|
918
|
+
const checksumsFile = path.join(cwd, '.magic', '.checksums');
|
|
919
|
+
if (fs.existsSync(checksumsFile)) {
|
|
920
|
+
try {
|
|
921
|
+
const oldChecksums = JSON.parse(fs.readFileSync(checksumsFile, 'utf8'));
|
|
922
|
+
for (const skipped of conflictsToSkip) {
|
|
923
|
+
if (oldChecksums[skipped]) {
|
|
924
|
+
currentChecksums[skipped] = oldChecksums[skipped];
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
} catch (e) { }
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
Object.assign(currentChecksums, adapterChecksums);
|
|
932
|
+
|
|
859
933
|
fs.writeFileSync(path.join(cwd, '.magic', '.checksums'), JSON.stringify(currentChecksums, null, 2), 'utf8');
|
|
860
934
|
} catch (cErr) {
|
|
861
935
|
console.warn(`⚠️ Failed to save checksums: ${cErr.message}`);
|