@platformos/pos-cli
Version:
Manage your platformOS application
260 lines (231 loc) • 11.3 kB
JavaScript
// sync.singleFile tool extracted from tools.js for maintainability
import fs from 'fs';
import path from 'path';
// Reuse pos-cli internals (ESM)
import files from '../../lib/files.js';
import { loadSettingsFileForModule } from '../../lib/settings.js';
import shouldBeSynced from '../../lib/shouldBeSynced.js';
import Gateway from '../../lib/proxy.js';
import { presignDirectory } from '../../lib/presignUrl.js';
import { uploadFileFormData } from '../../lib/s3UploadFile.js';
import { manifestGenerateForAssets } from '../../lib/assets/manifest.js';
import { fillInTemplateValues } from '../../lib/templates.js';
import dir from '../../lib/directories.js';
import log from '../log.js';
import { resolveAuth, maskToken, runWithAuth } from '../auth.js';
// Alias for backwards compatibility
const templates = { fillInTemplateValues };
function toPosix(p) {
return p.replace(/\\/g, '/');
}
function normalizeLocalPath(filePathParam) {
const abs = path.resolve(filePathParam);
const rel = path.relative(process.cwd(), abs);
return toPosix(rel);
}
function computeRemotePath(relPath) {
// remove leading app/ or marketplace_builder/ like pos-cli watch.filePathUnixified
const posix = toPosix(relPath);
const reApp = new RegExp(`^${dir.APP}/`);
const reLegacy = new RegExp(`^${dir.LEGACY_APP}/`);
return posix.replace(reApp, '').replace(reLegacy, '');
}
function isAssetsPath(relPath) {
return relPath.startsWith('app/assets') || /^modules\/\w+\/public\/assets/.test(relPath);
}
async function uploadAsset({ gateway, relPath, log }) {
// Prepare direct upload data
const instance = await gateway.getInstance();
const remoteAssetsDir = `instances/${instance.id}/assets`;
const data = await presignDirectory(remoteAssetsDir);
const dirname = path.posix.dirname(relPath);
const fileSubdir = relPath.startsWith('app/assets')
? dirname.replace('app/assets', '')
: '/' + dirname.replace('/public/assets', '');
const key = data.fields.key.replace('assets/${filename}', `assets${fileSubdir}/${filename}`);
data.fields.key = key;
log?.(`[sync-file] Uploading asset to S3: ${relPath}`);
log?.(`[sync-file] Presigned URL: ${data.url}`);
log?.(`[sync-file] FormData fields: ${JSON.stringify(Object.keys(data.fields))}`);
await uploadFileFormData(relPath, data);
const manifest = manifestGenerateForAssets([relPath]);
await gateway.sendManifest(manifest);
return { ok: true };
}
async function uploadNonAsset({ gateway, relPath, log }) {
const remotePath = computeRemotePath(relPath);
const processTemplate = remotePath.startsWith('modules');
let body;
if (processTemplate) {
const moduleName = relPath.split('/')[1];
const moduleData = loadSettingsFileForModule(moduleName);
body = templates.fillInTemplateValues(relPath, moduleData);
log?.(`[sync-file] Processing template for module: ${moduleName}`);
} else {
body = fs.createReadStream(relPath);
log?.(`[sync-file] Streaming file: ${relPath}`);
}
const formData = { path: remotePath, marketplace_builder_file_body: body };
log?.(`[sync-file] Sync formData: path=${remotePath}, body type=${processTemplate ? 'template' : 'stream'}`);
const resp = await gateway.sync(formData);
return { ok: true, response: resp };
}
async function deleteRemote({ gateway, relPath }) {
const remotePath = computeRemotePath(relPath);
const formData = { path: remotePath, primary_key: remotePath };
const resp = await gateway.delete(formData);
return { ok: true, response: resp };
}
const singleFileTool = {
description: 'Sync a single file to a platformOS instance (upload or delete). Handles assets (direct S3 upload + manifest) and non-assets (gateway sync) automatically. Respects .posignore rules. Auth resolved from: explicit params > MPKIT_* env vars > .pos config. Use dryRun to validate without sending.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
filePath: { type: 'string', description: 'Absolute or relative path to the file to sync. Must be inside app/, marketplace_builder/, or modules/.' },
env: { type: 'string', description: 'Environment name from .pos config (e.g., staging, production). Used to resolve auth when url/email/token are not provided.' },
url: { type: 'string', description: 'Instance URL (e.g., https://my-app.staging.oregon.platform-os.com). Requires email and token.' },
email: { type: 'string', description: 'Email for instance authentication. Required with url and token.' },
token: { type: 'string', description: 'API token for instance authentication. Required with url and email.' },
op: { type: 'string', enum: ['upload', 'delete'], description: 'Operation: "upload" to push file, "delete" to remove from instance. Auto-detected from file existence if omitted.' },
dryRun: { type: 'boolean', description: 'Validate file path, auth, and sync rules without actually uploading. Default: false.' },
confirmDelete: { type: 'boolean', description: 'Safety flag -- must be true to execute delete operations. Default: false.' }
},
required: ['filePath']
},
handler: async (params, ctx) => {
const startedAt = new Date().toISOString();
const logFn = ctx?.log || log.info.bind(log);
const { filePath, op: opParam, dryRun = false, confirmDelete = false } = params || {};
if (!filePath || typeof filePath !== 'string') {
throw new Error('INVALID_PARAM: filePath is required');
}
const relPath = normalizeLocalPath(filePath);
const absPath = path.resolve(filePath);
logFn(`[sync-file] Processing file: ${filePath} (normalized: ${relPath})`);
// Validate location
const allowedPrefixes = [dir.APP + '/', dir.LEGACY_APP + '/', dir.MODULES + '/'];
const inAllowedDir = allowedPrefixes.some((p) => toPosix(relPath).startsWith(p));
if (!inAllowedDir) {
logFn(`[sync-file] File outside allowed directories: ${relPath}`);
return {
ok: false,
operation: 'noop',
error: { code: 'FILE_OUTSIDE_ALLOWED_DIRECTORIES', message: `File must be inside ${allowedPrefixes.join(', ')}` },
file: { localPath: filePath, normalizedPath: relPath }
};
}
const ignoreList = files.getIgnoreList();
const should = shouldBeSynced(relPath, ignoreList);
logFn(`[sync-file] Sync check for ${relPath}: shouldSync=${should}, ignoreList rules=${ignoreList.length}`);
if (!should && opParam !== 'delete') {
return {
ok: false,
operation: 'noop',
error: { code: 'IGNORED_BY_RULES', message: 'File is ignored by .posignore or rules' },
file: { localPath: filePath, normalizedPath: relPath }
};
}
const exists = fs.existsSync(absPath);
const op = opParam || (exists ? 'upload' : 'delete');
logFn(`[sync-file] Operation determined: ${op} (file exists: ${exists})`);
// Resolve auth
const auth = await resolveAuth(params, ctx);
logFn(`[sync-file] Auth resolved from: ${auth.source}, URL: ${auth.url}`);
if (dryRun) {
return {
ok: true,
operation: op,
file: {
localPath: filePath,
normalizedPath: relPath,
isAsset: isAssetsPath(relPath),
size: exists ? fs.statSync(absPath).size : null
},
server: { responseCode: null, method: null },
timings: { startedAt, finishedAt: new Date().toISOString(), durationMs: 0 },
auth: { url: auth.url, email: auth.email, token: maskToken(auth.token), source: auth.source }
};
}
const gateway = new Gateway({ url: auth.url, token: auth.token, email: auth.email });
try {
return await runWithAuth(auth, async () => {
process.env.SYNC_SINGLE = 'true';
if (op === 'delete') {
logFn(`[sync-file] Starting delete operation for: ${relPath}`);
if (!confirmDelete) {
return {
ok: false,
operation: 'delete',
error: { code: 'DELETE_PROTECTED', message: 'confirmDelete=true is required to delete' },
file: { localPath: filePath, normalizedPath: relPath }
};
}
const result = await deleteRemote({ gateway, relPath });
logFn(`[sync-file] Delete completed for: ${relPath}`);
return {
ok: true,
operation: 'delete',
file: { localPath: filePath, normalizedPath: computeRemotePath(relPath) },
server: { responseCode: 200, method: 'gateway.delete', gatewayResponse: result.response || null },
timings: { startedAt, finishedAt: new Date().toISOString() }
};
}
if (!exists) {
return {
ok: false,
operation: 'upload',
error: { code: 'FILE_NOT_FOUND', message: `Local file not found: ${filePath}` },
file: { localPath: filePath, normalizedPath: relPath }
};
}
if (isAssetsPath(relPath)) {
logFn(`[sync-file] Uploading asset: ${relPath}`);
await uploadAsset({ gateway, relPath, log: logFn });
logFn(`[sync-file] Asset upload completed: ${relPath}`);
return {
ok: true,
operation: 'update',
file: { localPath: filePath, normalizedPath: relPath, isAsset: true, size: fs.statSync(absPath).size },
// server: { responseCode: 200, method: 'asset.directUpload+manifest' },
timings: { startedAt, finishedAt: new Date().toISOString() }
};
} else {
logFn(`[sync-file] Uploading non-asset: ${relPath}`);
const res = await uploadNonAsset({ gateway, relPath, log: logFn });
logFn(`[sync-file] Non-asset upload completed: ${relPath}, response status: ${res.response?.status || 'unknown'}`);
return {
ok: true,
operation: 'update',
file: { localPath: filePath, normalizedPath: computeRemotePath(relPath), isAsset: false, size: fs.statSync(absPath).size },
// server: { responseCode: 200, method: 'gateway.sync', gatewayResponse: res.response || null },
timings: { startedAt, finishedAt: new Date().toISOString() }
};
}
});
} catch (e) {
// Extract response body details (422 validation errors, etc.)
const body = e?.response?.body;
const serverError = body?.error || (Array.isArray(body?.errors) && body.errors.join(', ')) || null;
const serverDetails = body?.details || null;
const statusCode = e?.statusCode || e?.response?.statusCode || null;
const detail = serverError || String(e?.message || e);
logFn(`[sync-file] Error during ${op} for ${relPath} (${statusCode}): ${detail}`);
const errPayload = {
code: 'GATEWAY_ERROR',
message: detail,
statusCode,
details: {
operation: op,
file: { localPath: filePath, normalizedPath: relPath },
...(serverDetails && { server: serverDetails })
}
};
const err = new Error(`${errPayload.code}: ${detail}`);
err._pos = errPayload;
throw err;
}
}
};
export default singleFileTool;
export { computeRemotePath, normalizeLocalPath, toPosix };