feat: sync full workspace including web modules, docs, and configurations to Gitea
Optimized the root .gitignore to exclude virtual environments, node modules, and temp folders to ensure clean and lightweight version tracking. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
778
axhub-make/scripts/canvas-fig-sync.mjs
Normal file
778
axhub-make/scripts/canvas-fig-sync.mjs
Normal file
@@ -0,0 +1,778 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import zlib from 'node:zlib';
|
||||
import { decodeBinarySchema, compileSchema } from '../../../packages/axhub-export-core/node_modules/kiwi-schema/kiwi-esm.js';
|
||||
import { inflateRaw } from '../../../packages/axhub-export-core/node_modules/pako/dist/pako.esm.mjs';
|
||||
|
||||
const PRELUDE_LENGTH = 8;
|
||||
const VERSION_OFFSET = PRELUDE_LENGTH;
|
||||
const PARTS_OFFSET = VERSION_OFFSET + 4;
|
||||
const DEFAULT_SOURCE_ROOT = 'src';
|
||||
const MANIFEST_FILENAME = 'canvas.code-manifest.json';
|
||||
|
||||
function printUsage() {
|
||||
console.log(`Usage:
|
||||
node scripts/canvas-fig-sync.mjs inspect --fig <canvas.fig> [--manifest <file>]
|
||||
node scripts/canvas-fig-sync.mjs extract --fig <canvas.fig> --out <project-dir> [--source-root src] [--manifest <file>]
|
||||
node scripts/canvas-fig-sync.mjs pack --fig <canvas.fig> --from <project-dir> [--source-root src] [--out <new-canvas.fig>] [--manifest <file>] [--prune-missing] [--sanitize-for-export]
|
||||
`);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const [command, ...rest] = argv;
|
||||
if (!command || command === '--help' || command === '-h') {
|
||||
return { command: 'help', options: {} };
|
||||
}
|
||||
|
||||
const options = {};
|
||||
for (let index = 0; index < rest.length; index += 1) {
|
||||
const token = rest[index];
|
||||
if (!token.startsWith('--')) {
|
||||
throw new Error(`Unexpected argument: ${token}`);
|
||||
}
|
||||
|
||||
const key = token.slice(2);
|
||||
const value = rest[index + 1];
|
||||
if (!value || value.startsWith('--')) {
|
||||
options[key] = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
options[key] = value;
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return { command, options };
|
||||
}
|
||||
|
||||
function getRequiredOption(options, key) {
|
||||
const value = options[key];
|
||||
if (!value) {
|
||||
throw new Error(`Missing required option --${key}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function resolvePath(value) {
|
||||
return path.resolve(value);
|
||||
}
|
||||
|
||||
function sha1(content) {
|
||||
return crypto.createHash('sha1').update(content).digest('hex');
|
||||
}
|
||||
|
||||
function toPosixPath(value) {
|
||||
return value.split(path.sep).join(path.posix.sep);
|
||||
}
|
||||
|
||||
function normalizeRelativePath(value) {
|
||||
const normalized = path.posix.normalize(value).replace(/^\/+/, '');
|
||||
if (!normalized || normalized === '.') {
|
||||
return '';
|
||||
}
|
||||
if (normalized.startsWith('../') || normalized === '..') {
|
||||
throw new Error(`Unsafe relative path: ${value}`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function joinLogicalPath(codeFilePath, name) {
|
||||
const basePath = codeFilePath ? normalizeRelativePath(toPosixPath(codeFilePath)) : '';
|
||||
const fileName = normalizeRelativePath(name);
|
||||
return basePath ? path.posix.join(basePath, fileName) : fileName;
|
||||
}
|
||||
|
||||
function normalizeSourceRoot(sourceRoot) {
|
||||
return normalizeRelativePath(toPosixPath(sourceRoot || DEFAULT_SOURCE_ROOT));
|
||||
}
|
||||
|
||||
function guidToString(value) {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (value.guid && typeof value.guid === 'object') {
|
||||
return guidToString(value.guid);
|
||||
}
|
||||
|
||||
if (typeof value.sessionID === 'number' && typeof value.localID === 'number') {
|
||||
return `${value.sessionID}:${value.localID}`;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function createCollaborativeSourceCode(sourceCode, sessionID = 1) {
|
||||
const contentBuffer = Uint8Array.from(Buffer.from(sourceCode, 'utf8'));
|
||||
return {
|
||||
historyOpsWithIds: [
|
||||
{
|
||||
firstId: { sessionID, counterID: 1 },
|
||||
runLength: sourceCode.length,
|
||||
parentIds: [],
|
||||
},
|
||||
],
|
||||
historyOpsWithLoc: [
|
||||
{
|
||||
type: 'INSERT',
|
||||
range: { startIndex: 0, endIndexExclusive: sourceCode.length },
|
||||
contentBytesInBuffer: { startIndex: 0, endIndexExclusive: contentBuffer.length },
|
||||
},
|
||||
],
|
||||
historyStringContentBuffer: contentBuffer,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveProjectFilePath(projectDir, sourceRoot, logicalPath) {
|
||||
const root = normalizeSourceRoot(sourceRoot);
|
||||
const relativePath = root ? path.posix.join(root, logicalPath) : logicalPath;
|
||||
return path.resolve(projectDir, ...relativePath.split('/'));
|
||||
}
|
||||
|
||||
function ensureParentDirectory(filePath) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
}
|
||||
|
||||
function parseArchive(buffer) {
|
||||
if (buffer.byteLength < PARTS_OFFSET) {
|
||||
throw new Error('Archive is too small.');
|
||||
}
|
||||
|
||||
const prelude = Buffer.from(buffer.subarray(0, PRELUDE_LENGTH)).toString('utf8');
|
||||
const version = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength).getUint32(
|
||||
VERSION_OFFSET,
|
||||
true,
|
||||
);
|
||||
const parts = [];
|
||||
let offset = PARTS_OFFSET;
|
||||
|
||||
while (offset + 4 <= buffer.byteLength) {
|
||||
const size = new DataView(buffer.buffer, buffer.byteOffset + offset, 4).getUint32(0, true);
|
||||
offset += 4;
|
||||
if (offset + size > buffer.byteLength) {
|
||||
throw new Error(`Invalid archive bounds at offset ${offset}.`);
|
||||
}
|
||||
parts.push(buffer.subarray(offset, offset + size));
|
||||
offset += size;
|
||||
}
|
||||
|
||||
return { prelude, version, parts };
|
||||
}
|
||||
|
||||
function encodeArchive({ prelude, version, parts }) {
|
||||
const totalLength =
|
||||
PARTS_OFFSET + parts.reduce((sum, part) => sum + 4 + Buffer.byteLength(part), 0);
|
||||
const output = Buffer.alloc(totalLength);
|
||||
|
||||
output.write(prelude, 0, PRELUDE_LENGTH, 'utf8');
|
||||
output.writeUInt32LE(version, VERSION_OFFSET);
|
||||
|
||||
let offset = PARTS_OFFSET;
|
||||
for (const part of parts) {
|
||||
const buffer = Buffer.from(part);
|
||||
output.writeUInt32LE(buffer.length, offset);
|
||||
offset += 4;
|
||||
buffer.copy(output, offset);
|
||||
offset += buffer.length;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function loadCanvasFig(figPath) {
|
||||
const archiveBytes = new Uint8Array(fs.readFileSync(figPath));
|
||||
const { prelude, version, parts } = parseArchive(archiveBytes);
|
||||
|
||||
if (prelude !== 'fig-make') {
|
||||
throw new Error(`Unsupported prelude: ${prelude}`);
|
||||
}
|
||||
if (parts.length !== 2) {
|
||||
throw new Error(`Expected 2 archive parts, got ${parts.length}`);
|
||||
}
|
||||
|
||||
const schemaPart = parts[0];
|
||||
const messagePart = parts[1];
|
||||
const schemaBytes = inflateRaw(schemaPart);
|
||||
const schema = decodeBinarySchema(schemaBytes);
|
||||
const compiled = compileSchema(schema);
|
||||
const messageBytes = new Uint8Array(zlib.zstdDecompressSync(Buffer.from(messagePart)));
|
||||
const message = compiled.decodeMessage(messageBytes);
|
||||
|
||||
return {
|
||||
figPath,
|
||||
prelude,
|
||||
version,
|
||||
schemaPart,
|
||||
compiled,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCodeFileEntries(message) {
|
||||
const codeEntries = [];
|
||||
|
||||
for (const [nodeChangeIndex, node] of (message.nodeChanges || []).entries()) {
|
||||
if (node?.type !== 'CODE_FILE') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = node.name || `unnamed-${nodeChangeIndex}`;
|
||||
const codeFilePath = node.codeFilePath || '';
|
||||
const logicalPath = joinLogicalPath(codeFilePath, name);
|
||||
const sourceCode = node.sourceCode || '';
|
||||
|
||||
codeEntries.push({
|
||||
nodeChangeIndex,
|
||||
node,
|
||||
name,
|
||||
codeFilePath,
|
||||
logicalPath,
|
||||
sourceCode,
|
||||
sourceCodeSha1: sha1(sourceCode),
|
||||
});
|
||||
}
|
||||
|
||||
const duplicateMap = new Map();
|
||||
for (const entry of codeEntries) {
|
||||
duplicateMap.set(entry.logicalPath, (duplicateMap.get(entry.logicalPath) || 0) + 1);
|
||||
}
|
||||
|
||||
return codeEntries.map((entry) => ({
|
||||
...entry,
|
||||
isDuplicate: (duplicateMap.get(entry.logicalPath) || 0) > 1,
|
||||
duplicateCount: duplicateMap.get(entry.logicalPath) || 1,
|
||||
}));
|
||||
}
|
||||
|
||||
function summarizeEntries(entries) {
|
||||
const pathCounts = new Map();
|
||||
const duplicateGroups = [];
|
||||
const grouped = new Map();
|
||||
|
||||
for (const entry of entries) {
|
||||
const codeFilePath = entry.codeFilePath || '(root)';
|
||||
pathCounts.set(codeFilePath, (pathCounts.get(codeFilePath) || 0) + 1);
|
||||
|
||||
if (!grouped.has(entry.logicalPath)) {
|
||||
grouped.set(entry.logicalPath, []);
|
||||
}
|
||||
grouped.get(entry.logicalPath).push(entry.nodeChangeIndex);
|
||||
}
|
||||
|
||||
for (const [logicalPath, indices] of grouped.entries()) {
|
||||
if (indices.length > 1) {
|
||||
duplicateGroups.push({
|
||||
logicalPath,
|
||||
nodeChangeIndices: indices,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalCodeFiles: entries.length,
|
||||
pathCounts: Object.fromEntries([...pathCounts.entries()].sort(([a], [b]) => a.localeCompare(b))),
|
||||
duplicateGroups,
|
||||
};
|
||||
}
|
||||
|
||||
function collectCodeGraph(message) {
|
||||
const codeFiles = [];
|
||||
const codeFilesByGuid = new Map();
|
||||
const codeFilesByLogicalPath = new Map();
|
||||
|
||||
for (const [nodeChangeIndex, node] of (message.nodeChanges || []).entries()) {
|
||||
if (node?.type !== 'CODE_FILE') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const logicalPath = joinLogicalPath(node.codeFilePath || '', node.name || `unnamed-${nodeChangeIndex}`);
|
||||
const guid = guidToString(node.guid);
|
||||
const codeFile = {
|
||||
nodeChangeIndex,
|
||||
node,
|
||||
guid,
|
||||
logicalPath,
|
||||
};
|
||||
codeFiles.push(codeFile);
|
||||
|
||||
if (guid) {
|
||||
codeFilesByGuid.set(guid, codeFile);
|
||||
}
|
||||
if (!codeFilesByLogicalPath.has(logicalPath)) {
|
||||
codeFilesByLogicalPath.set(logicalPath, []);
|
||||
}
|
||||
codeFilesByLogicalPath.get(logicalPath).push(codeFile);
|
||||
}
|
||||
|
||||
return {
|
||||
codeFiles,
|
||||
codeFilesByGuid,
|
||||
codeFilesByLogicalPath,
|
||||
};
|
||||
}
|
||||
|
||||
function listRelativeImportSpecifiers(sourceCode) {
|
||||
const specifiers = new Set();
|
||||
const patterns = [
|
||||
/import\s+[^'"]*?from\s+['"]([^'"]+)['"]/g,
|
||||
/import\s*['"]([^'"]+)['"]/g,
|
||||
/export\s+[^'"]*?from\s+['"]([^'"]+)['"]/g,
|
||||
/import\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
pattern.lastIndex = 0;
|
||||
for (let match = pattern.exec(sourceCode); match; match = pattern.exec(sourceCode)) {
|
||||
const specifier = match[1];
|
||||
if (specifier && specifier.startsWith('.')) {
|
||||
specifiers.add(specifier);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...specifiers];
|
||||
}
|
||||
|
||||
function resolveImportToLogicalPath(fromLogicalPath, specifier, availableLogicalPaths) {
|
||||
const baseDir = path.posix.dirname(fromLogicalPath);
|
||||
const normalizedBaseDir = baseDir === '.' ? '' : baseDir;
|
||||
const resolvedBase = path.posix.normalize(
|
||||
normalizedBaseDir ? path.posix.join(normalizedBaseDir, specifier) : specifier,
|
||||
);
|
||||
|
||||
const candidates = [
|
||||
resolvedBase,
|
||||
`${resolvedBase}.ts`,
|
||||
`${resolvedBase}.tsx`,
|
||||
`${resolvedBase}.js`,
|
||||
`${resolvedBase}.jsx`,
|
||||
`${resolvedBase}.css`,
|
||||
path.posix.join(resolvedBase, 'index.ts'),
|
||||
path.posix.join(resolvedBase, 'index.tsx'),
|
||||
path.posix.join(resolvedBase, 'index.js'),
|
||||
path.posix.join(resolvedBase, 'index.jsx'),
|
||||
path.posix.join(resolvedBase, 'index.css'),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const normalized = normalizeRelativePath(candidate);
|
||||
if (availableLogicalPaths.has(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function sanitizeForExport(message) {
|
||||
const warnings = [];
|
||||
const codeGraph = collectCodeGraph(message);
|
||||
const availableLogicalPaths = new Set(codeGraph.codeFiles.map((codeFile) => codeFile.logicalPath));
|
||||
const existingCodeFileGuids = new Set(codeGraph.codeFiles.map((codeFile) => codeFile.guid).filter(Boolean));
|
||||
let clearedChatMessageCount = 0;
|
||||
let clearedLibraryCount = 0;
|
||||
let rebuiltImportReferenceCount = 0;
|
||||
let prunedDanglingImportReferenceCount = 0;
|
||||
let prunedCodeComponentCount = 0;
|
||||
let clearedCodeInstanceSnapshotCount = 0;
|
||||
|
||||
for (const node of message.nodeChanges || []) {
|
||||
if (node?.type !== 'CODE_LIBRARY') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const chatMessages = Array.isArray(node.chatMessages) ? node.chatMessages : [];
|
||||
if (chatMessages.length > 0) {
|
||||
clearedChatMessageCount += chatMessages.length;
|
||||
node.chatMessages = [];
|
||||
}
|
||||
if (node.chatCompressionState !== undefined) {
|
||||
delete node.chatCompressionState;
|
||||
}
|
||||
clearedLibraryCount += 1;
|
||||
}
|
||||
|
||||
for (const node of message.nodeChanges || []) {
|
||||
if (node?.type !== 'CODE_INSTANCE') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (node.codeSnapshot !== undefined) {
|
||||
delete node.codeSnapshot;
|
||||
clearedCodeInstanceSnapshotCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for (const codeFile of codeGraph.codeFiles) {
|
||||
const importedLogicalPaths = [];
|
||||
for (const specifier of listRelativeImportSpecifiers(codeFile.node.sourceCode || '')) {
|
||||
const resolved = resolveImportToLogicalPath(codeFile.logicalPath, specifier, availableLogicalPaths);
|
||||
if (resolved) {
|
||||
importedLogicalPaths.push(resolved);
|
||||
continue;
|
||||
}
|
||||
warnings.push(`Unresolved relative import ${specifier} in ${codeFile.logicalPath}; omitted from importedCodeFiles.`);
|
||||
}
|
||||
|
||||
const uniqueImportedPaths = [...new Set(importedLogicalPaths)].filter(
|
||||
(logicalPath) => logicalPath !== codeFile.logicalPath,
|
||||
);
|
||||
|
||||
const nextEntries = [];
|
||||
for (const logicalPath of uniqueImportedPaths) {
|
||||
const target = codeGraph.codeFilesByLogicalPath.get(logicalPath)?.[0];
|
||||
if (!target?.guid) {
|
||||
continue;
|
||||
}
|
||||
nextEntries.push({
|
||||
codeFileId: {
|
||||
guid: target.node.guid,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const previousEntries = Array.isArray(codeFile.node.importedCodeFiles?.entries)
|
||||
? codeFile.node.importedCodeFiles.entries
|
||||
: [];
|
||||
const previousGuidCount = previousEntries.filter((entry) => existingCodeFileGuids.has(guidToString(entry?.codeFileId))).length;
|
||||
prunedDanglingImportReferenceCount += Math.max(0, previousEntries.length - previousGuidCount);
|
||||
rebuiltImportReferenceCount += nextEntries.length;
|
||||
|
||||
if (nextEntries.length > 0) {
|
||||
codeFile.node.importedCodeFiles = { entries: nextEntries };
|
||||
} else {
|
||||
delete codeFile.node.importedCodeFiles;
|
||||
}
|
||||
}
|
||||
|
||||
message.nodeChanges = (message.nodeChanges || []).filter((node) => {
|
||||
if (node?.type !== 'CODE_COMPONENT') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const exportedFromGuid = guidToString(node.exportedFromCodeFileId);
|
||||
if (exportedFromGuid && existingCodeFileGuids.has(exportedFromGuid)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
prunedCodeComponentCount += 1;
|
||||
warnings.push(`Pruned CODE_COMPONENT ${node.name || '(unnamed)'} because exportedFromCodeFileId no longer exists.`);
|
||||
return false;
|
||||
});
|
||||
|
||||
return {
|
||||
warnings,
|
||||
clearedChatMessageCount,
|
||||
clearedLibraryCount,
|
||||
clearedCodeInstanceSnapshotCount,
|
||||
rebuiltImportReferenceCount,
|
||||
prunedDanglingImportReferenceCount,
|
||||
prunedCodeComponentCount,
|
||||
};
|
||||
}
|
||||
|
||||
function buildBaseManifest(command, figData, entries, sourceRoot) {
|
||||
const summary = summarizeEntries(entries);
|
||||
return {
|
||||
command,
|
||||
generatedAt: new Date().toISOString(),
|
||||
figPath: figData.figPath,
|
||||
archive: {
|
||||
prelude: figData.prelude,
|
||||
version: figData.version,
|
||||
parts: 2,
|
||||
},
|
||||
sourceRoot: normalizeSourceRoot(sourceRoot),
|
||||
summary,
|
||||
};
|
||||
}
|
||||
|
||||
function defaultManifestPath(command, figPath, options) {
|
||||
if (options.manifest) {
|
||||
return resolvePath(options.manifest);
|
||||
}
|
||||
|
||||
const figBaseName = path.basename(
|
||||
command === 'pack' ? resolvePath(options.out || options.fig) : resolvePath(options.fig),
|
||||
path.extname(command === 'pack' ? resolvePath(options.out || options.fig) : resolvePath(options.fig)),
|
||||
);
|
||||
|
||||
if (command === 'extract') {
|
||||
return path.resolve(resolvePath(options.out), MANIFEST_FILENAME);
|
||||
}
|
||||
|
||||
const baseDir =
|
||||
command === 'pack'
|
||||
? path.dirname(resolvePath(options.out || options.fig))
|
||||
: path.dirname(resolvePath(options.fig));
|
||||
return path.resolve(baseDir, `${figBaseName}.code-manifest.json`);
|
||||
}
|
||||
|
||||
function writeManifest(manifestPath, manifest) {
|
||||
ensureParentDirectory(manifestPath);
|
||||
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
function extractCommand(options) {
|
||||
const figPath = resolvePath(getRequiredOption(options, 'fig'));
|
||||
const outputDir = resolvePath(getRequiredOption(options, 'out'));
|
||||
const sourceRoot = options['source-root'] || DEFAULT_SOURCE_ROOT;
|
||||
const manifestPath = defaultManifestPath('extract', figPath, options);
|
||||
const figData = loadCanvasFig(figPath);
|
||||
const entries = buildCodeFileEntries(figData.message);
|
||||
|
||||
const latestByLogicalPath = new Map();
|
||||
for (const entry of entries) {
|
||||
latestByLogicalPath.set(entry.logicalPath, entry);
|
||||
}
|
||||
|
||||
const manifestEntries = [];
|
||||
for (const entry of entries) {
|
||||
const outputFilePath = resolveProjectFilePath(outputDir, sourceRoot, entry.logicalPath);
|
||||
const isLatest = latestByLogicalPath.get(entry.logicalPath)?.nodeChangeIndex === entry.nodeChangeIndex;
|
||||
const status = isLatest ? 'written' : 'shadowed-by-later-duplicate';
|
||||
|
||||
if (isLatest) {
|
||||
ensureParentDirectory(outputFilePath);
|
||||
fs.writeFileSync(outputFilePath, entry.sourceCode, 'utf8');
|
||||
}
|
||||
|
||||
manifestEntries.push({
|
||||
nodeChangeIndex: entry.nodeChangeIndex,
|
||||
name: entry.name,
|
||||
codeFilePath: entry.codeFilePath || null,
|
||||
logicalPath: entry.logicalPath,
|
||||
sourceCodeSha1: entry.sourceCodeSha1,
|
||||
isDuplicate: entry.isDuplicate,
|
||||
duplicateCount: entry.duplicateCount,
|
||||
extractedPath: outputFilePath,
|
||||
extractStatus: status,
|
||||
});
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
...buildBaseManifest('extract', figData, entries, sourceRoot),
|
||||
outputDirectory: outputDir,
|
||||
entries: manifestEntries,
|
||||
};
|
||||
writeManifest(manifestPath, manifest);
|
||||
|
||||
const duplicateCount = manifest.summary.duplicateGroups.length;
|
||||
console.log(`Extracted ${manifest.summary.totalCodeFiles} CODE_FILE nodes from ${path.basename(figPath)}`);
|
||||
console.log(`Source root: ${normalizeSourceRoot(sourceRoot) || '.'}`);
|
||||
console.log(`Output directory: ${outputDir}`);
|
||||
console.log(`Manifest: ${manifestPath}`);
|
||||
if (duplicateCount > 0) {
|
||||
console.warn(`Warning: ${duplicateCount} duplicate logical path group(s) were resolved with last-wins semantics.`);
|
||||
}
|
||||
}
|
||||
|
||||
function inspectCommand(options) {
|
||||
const figPath = resolvePath(getRequiredOption(options, 'fig'));
|
||||
const manifestPath = defaultManifestPath('inspect', figPath, options);
|
||||
const figData = loadCanvasFig(figPath);
|
||||
const entries = buildCodeFileEntries(figData.message);
|
||||
const manifest = {
|
||||
...buildBaseManifest('inspect', figData, entries, DEFAULT_SOURCE_ROOT),
|
||||
entries: entries.map((entry) => ({
|
||||
nodeChangeIndex: entry.nodeChangeIndex,
|
||||
name: entry.name,
|
||||
codeFilePath: entry.codeFilePath || null,
|
||||
logicalPath: entry.logicalPath,
|
||||
sourceCodeSha1: entry.sourceCodeSha1,
|
||||
isDuplicate: entry.isDuplicate,
|
||||
duplicateCount: entry.duplicateCount,
|
||||
})),
|
||||
};
|
||||
writeManifest(manifestPath, manifest);
|
||||
|
||||
console.log(`FIG: ${figPath}`);
|
||||
console.log(`Prelude: ${figData.prelude}`);
|
||||
console.log(`Version: ${figData.version}`);
|
||||
console.log(`CODE_FILE nodes: ${manifest.summary.totalCodeFiles}`);
|
||||
console.log('Path distribution:');
|
||||
for (const [codeFilePath, count] of Object.entries(manifest.summary.pathCounts)) {
|
||||
console.log(` ${codeFilePath}: ${count}`);
|
||||
}
|
||||
if (manifest.summary.duplicateGroups.length > 0) {
|
||||
console.log('Duplicate logical paths:');
|
||||
for (const duplicate of manifest.summary.duplicateGroups) {
|
||||
console.log(` ${duplicate.logicalPath} -> [${duplicate.nodeChangeIndices.join(', ')}]`);
|
||||
}
|
||||
} else {
|
||||
console.log('Duplicate logical paths: none');
|
||||
}
|
||||
console.log(`Manifest: ${manifestPath}`);
|
||||
}
|
||||
|
||||
function packCommand(options) {
|
||||
const figPath = resolvePath(getRequiredOption(options, 'fig'));
|
||||
const projectDir = resolvePath(getRequiredOption(options, 'from'));
|
||||
const sourceRoot = options['source-root'] || DEFAULT_SOURCE_ROOT;
|
||||
const outputFigPath = resolvePath(options.out || figPath);
|
||||
const manifestPath = defaultManifestPath('pack', outputFigPath, options);
|
||||
const pruneMissing = options['prune-missing'] === true || options['prune-missing'] === 'true';
|
||||
const sanitizeForExportMode =
|
||||
options['sanitize-for-export'] === true || options['sanitize-for-export'] === 'true';
|
||||
const figData = loadCanvasFig(figPath);
|
||||
const entries = buildCodeFileEntries(figData.message);
|
||||
|
||||
const byLogicalPath = new Map();
|
||||
for (const entry of entries) {
|
||||
if (!byLogicalPath.has(entry.logicalPath)) {
|
||||
byLogicalPath.set(entry.logicalPath, []);
|
||||
}
|
||||
byLogicalPath.get(entry.logicalPath).push(entry);
|
||||
}
|
||||
|
||||
const manifestEntries = [];
|
||||
const warnings = [];
|
||||
const updatedLogicalPaths = new Set();
|
||||
const prunedLogicalPaths = new Set();
|
||||
const prunedNodeChangeIndices = new Set();
|
||||
|
||||
for (const [logicalPath, group] of byLogicalPath.entries()) {
|
||||
const projectFilePath = resolveProjectFilePath(projectDir, sourceRoot, logicalPath);
|
||||
const exists = fs.existsSync(projectFilePath);
|
||||
|
||||
if (!exists) {
|
||||
if (pruneMissing) {
|
||||
warnings.push(`Missing source file for ${logicalPath}; pruned ${group.length} CODE_FILE node(s).`);
|
||||
prunedLogicalPaths.add(logicalPath);
|
||||
for (const entry of group) {
|
||||
prunedNodeChangeIndices.add(entry.nodeChangeIndex);
|
||||
manifestEntries.push({
|
||||
nodeChangeIndex: entry.nodeChangeIndex,
|
||||
name: entry.name,
|
||||
codeFilePath: entry.codeFilePath || null,
|
||||
logicalPath,
|
||||
sourceCodeSha1: entry.sourceCodeSha1,
|
||||
isDuplicate: entry.isDuplicate,
|
||||
duplicateCount: entry.duplicateCount,
|
||||
packedPath: projectFilePath,
|
||||
packStatus: 'pruned-missing-file',
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
warnings.push(`Missing source file for ${logicalPath}; preserved original canvas.fig content.`);
|
||||
for (const entry of group) {
|
||||
manifestEntries.push({
|
||||
nodeChangeIndex: entry.nodeChangeIndex,
|
||||
name: entry.name,
|
||||
codeFilePath: entry.codeFilePath || null,
|
||||
logicalPath,
|
||||
sourceCodeSha1: entry.sourceCodeSha1,
|
||||
isDuplicate: entry.isDuplicate,
|
||||
duplicateCount: entry.duplicateCount,
|
||||
packedPath: projectFilePath,
|
||||
packStatus: 'preserved-missing-file',
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextSource = fs.readFileSync(projectFilePath, 'utf8');
|
||||
const nextSha1 = sha1(nextSource);
|
||||
if (group.length > 1) {
|
||||
warnings.push(`Duplicate logical path ${logicalPath} updated across ${group.length} CODE_FILE nodes.`);
|
||||
}
|
||||
|
||||
for (const entry of group) {
|
||||
entry.node.sourceCode = nextSource;
|
||||
entry.node.collaborativeSourceCode = createCollaborativeSourceCode(
|
||||
nextSource,
|
||||
entry.node.guid?.sessionID ?? 1,
|
||||
);
|
||||
updatedLogicalPaths.add(logicalPath);
|
||||
manifestEntries.push({
|
||||
nodeChangeIndex: entry.nodeChangeIndex,
|
||||
name: entry.name,
|
||||
codeFilePath: entry.codeFilePath || null,
|
||||
logicalPath,
|
||||
sourceCodeSha1: nextSha1,
|
||||
isDuplicate: entry.isDuplicate,
|
||||
duplicateCount: entry.duplicateCount,
|
||||
packedPath: projectFilePath,
|
||||
packStatus: 'updated-from-disk',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (pruneMissing && prunedNodeChangeIndices.size > 0) {
|
||||
figData.message.nodeChanges = (figData.message.nodeChanges || []).filter((_, index) => !prunedNodeChangeIndices.has(index));
|
||||
}
|
||||
|
||||
let exportSanitization = null;
|
||||
if (sanitizeForExportMode) {
|
||||
exportSanitization = sanitizeForExport(figData.message);
|
||||
warnings.push(...exportSanitization.warnings);
|
||||
}
|
||||
|
||||
const encodedMessage = figData.compiled.encodeMessage(figData.message);
|
||||
const compressedMessage = zlib.zstdCompressSync(Buffer.from(encodedMessage));
|
||||
const encodedArchive = encodeArchive({
|
||||
prelude: figData.prelude,
|
||||
version: figData.version,
|
||||
parts: [figData.schemaPart, compressedMessage],
|
||||
});
|
||||
|
||||
ensureParentDirectory(outputFigPath);
|
||||
fs.writeFileSync(outputFigPath, encodedArchive);
|
||||
|
||||
const finalEntries = buildCodeFileEntries(figData.message);
|
||||
|
||||
const manifest = {
|
||||
...buildBaseManifest('pack', figData, finalEntries, sourceRoot),
|
||||
projectDirectory: projectDir,
|
||||
outputFigPath,
|
||||
updatedLogicalPathCount: updatedLogicalPaths.size,
|
||||
prunedLogicalPathCount: prunedLogicalPaths.size,
|
||||
sanitizeForExport: exportSanitization,
|
||||
warnings,
|
||||
entries: manifestEntries,
|
||||
};
|
||||
writeManifest(manifestPath, manifest);
|
||||
|
||||
console.log(`Packed ${updatedLogicalPaths.size} logical path(s) into ${outputFigPath}`);
|
||||
if (prunedLogicalPaths.size > 0) {
|
||||
console.log(`Pruned ${prunedLogicalPaths.size} logical path(s) without source files.`);
|
||||
}
|
||||
console.log(`Manifest: ${manifestPath}`);
|
||||
if (warnings.length > 0) {
|
||||
for (const warning of warnings) {
|
||||
console.warn(`Warning: ${warning}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
try {
|
||||
const { command, options } = parseArgs(process.argv.slice(2));
|
||||
|
||||
switch (command) {
|
||||
case 'help':
|
||||
printUsage();
|
||||
return;
|
||||
case 'inspect':
|
||||
inspectCommand(options);
|
||||
return;
|
||||
case 'extract':
|
||||
extractCommand(options);
|
||||
return;
|
||||
case 'pack':
|
||||
packCommand(options);
|
||||
return;
|
||||
default:
|
||||
throw new Error(`Unknown command: ${command}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
printUsage();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user