@flourish/sdk
Version:
The Flourish SDK
642 lines (573 loc) • 19.6 kB
JavaScript
;
const fs = require("fs"),
path = require("path"),
cross_spawn = require("cross-spawn"),
shell_quote = require("shell-quote"),
yaml = require("js-yaml"),
nodeResolve = require("resolve"),
semver = require("@flourish/semver"),
log = require("./log"),
validateConfig = require("./validate_config"),
{ extendItem } = require("./common") ;
const sdk_tokens_file = path.join(process.env.HOME || process.env.USERPROFILE, ".flourish_sdk");
const YAML_DUMP_OPTS = { flowLevel: 4 };
const SDK_VERSION = require("../package.json").version;
const SDK_MAJOR_VERSION = semver.parse(SDK_VERSION)[0];
const SDK_MAJOR_VERSION_COMPAT = 3; // Flourish templates built for SDK version 3 are compatible with the current version
function getSdkToken(server_opts) {
return new Promise(function(resolve, reject) {
fs.chmod(sdk_tokens_file, 0o600, function(error) {
if (error) { return reject(error); }
fs.readFile(sdk_tokens_file, "utf8", function(error, body) {
if (error) { return reject(error); }
resolve(JSON.parse(body)[server_opts.host]);
});
});
});
}
function setSdkToken(server_opts, sdk_token) {
return new Promise(function(resolve, reject) {
fs.readFile(sdk_tokens_file, function(error, body) {
let sdk_tokens;
if (error && error.code === "ENOENT") {
sdk_tokens = {};
}
else {
if (error) { log.die(`Failed to read ${sdk_tokens_file}`, error.message); }
try {
sdk_tokens = JSON.parse(body);
}
catch (error) {
log.die(`Failed to parse ${sdk_tokens_file}`, "Remove it and try again");
}
}
sdk_tokens[server_opts.host] = sdk_token;
fs.writeFile(sdk_tokens_file, JSON.stringify(sdk_tokens), { mode: 0o600 }, function(error) {
if (error) { log.die(`Failed to save ${sdk_tokens_file}`, error.message); }
resolve();
});
});
});
}
function deleteSdkToken(host) {
return new Promise(function(resolve) {
if (host == null) {
log.die("No host specified");
}
fs.readFile(sdk_tokens_file, function(error, body) {
if (error) { log.die(`Failed to read ${sdk_tokens_file}`, error.message); }
let sdk_tokens;
try {
sdk_tokens = JSON.parse(body);
}
catch (error) {
log.die(`Failed to parse ${sdk_tokens_file}`, "Remove it and try again");
}
delete sdk_tokens[host];
fs.writeFile(sdk_tokens_file, JSON.stringify(sdk_tokens), { mode: 0o600 }, function(error) {
if (error) { log.die(`Failed to save ${sdk_tokens_file}`, error.message); }
resolve();
});
});
});
}
/**
* Deletes all SDK tokens
* this will delete the .flourish_sdk file from the user's home directory
* which clears all tokens across all configured hosts
*/
function deleteSdkTokens() {
return new Promise(function(resolve, reject) {
fs.unlink(sdk_tokens_file, function(error) {
if (error) { log.die("Failed to delete " + sdk_tokens_file, error.message); }
resolve();
});
});
}
const AUTHENTICATED_REQUEST_METHODS = new Set([
"template/assign-version-number", "template/publish", "template/delete", "template/list", "template/history",
"user/whoami", "user/logout",
]);
async function request(server_opts, method, data, config = { exit_on_failure: true }) {
const fetch = await import("node-fetch");
let sdk_token;
if (AUTHENTICATED_REQUEST_METHODS.has(method)) {
try {
sdk_token = await getSdkToken(server_opts);
}
catch (error) {
log.problem(`Failed to read ${sdk_tokens_file}`, error.message);
}
if (!sdk_token) {
log.die("You are not logged in. Try ‘flourish login’ or ‘flourish register’ first.");
}
}
const protocol = server_opts.host.match(/^(localhost|127\.0\.0\.1|.*\.local)(:\d+)?$/) ? "http:" : "https:";
const url = `${protocol}//${server_opts.host}/api/v1/${method}`;
const options = { method: data ? "POST" : "GET" };
if (data) {
if (data instanceof fetch.FormData) {
if (sdk_token) {
data.append("sdk_token", sdk_token);
}
data.append("sdk_version", SDK_VERSION);
options.body = data;
}
else {
options.body = JSON.stringify({ ...data, sdk_token, sdk_version: SDK_VERSION });
options.headers = { "content-type": "application/json" };
}
}
if (server_opts.user) {
options.headers.authorization = Buffer.from(`${server_opts.user}:${server_opts.password}`).toString("base64");
}
let res;
try {
res = await fetch.default(url, options);
}
catch (e) {
if (config.exit_on_failure) {
log.die(e);
}
else {
throw e;
}
}
let text;
try {
// We could use res.json() here, but we're interested in what the body
// is when it's *not* json (load balancer issues etc.).
text = await res.text();
}
catch (error) {
if (config.exit_on_failure) {
log.die("Failed to get response from server", error);
}
else {
throw error;
}
}
let body;
try {
body = JSON.parse(text);
}
catch (error) {
if (config.exit_on_failure) {
log.die("Failed to parse response body", res.status, error, text);
}
else { throw error; }
}
if (res.ok) {
return body;
}
if (body.error) {
if (config.exit_on_failure) {
log.die("Error from server", res.status, body.error.message);
}
else {
throw body.error;
}
}
log.die("Server error", res.status, JSON.stringify(body));
}
function runBuildCommand(template_dir, command, node_env) {
const command_parts = shell_quote.parse(command),
prog = command_parts[0],
args = command_parts.slice(1);
return new Promise(function(resolve, reject) {
log.info("Running build command: " + command);
try {
const env = process.env;
if (typeof node_env !== "undefined") { env.NODE_ENV = node_env; }
cross_spawn.spawn(prog, args, { cwd: template_dir, stdio: "inherit", env })
.on("error", function(error) {
reject(new Error(`Failed to run build command ‘${command}’: ${error.message}`));
})
.on("exit", function(exit_code) {
if (exit_code != 0) {
reject(new Error(`Failed to run build command ‘${command}’`));
}
resolve();
});
}
catch (error) {
reject(new Error(`Failed to run build command ‘${command}’ in ${template_dir}: ${error.message}`));
}
});
}
function buildTemplate(template_dir, node_env, purpose) {
return checkTemplateVersion(template_dir)
.then(() => installNodeModules(template_dir, node_env))
.then(() => buildRules(template_dir))
.then((build_rules) => Promise.all([...build_rules].map((rule) => {
// If we’re building the template in order to run it,
// and there is a watch script defined, don’t build it
// and rely on the watch script instead.
if (purpose !== "run" || !("watch" in rule)) {
return runBuildCommand(template_dir, rule.script, node_env);
}
})));
}
function readConfig(template_dir) {
return Promise.all([
readYaml(path.join(template_dir, "template.yml")),
readJson(path.join(template_dir, "package.json")),
]).then(([yaml, json]) => {
if (json) {
if (!("id" in yaml) && ("name" in json)) {
yaml.id = json.name;
}
if (!("author" in yaml) && ("author" in json)) {
yaml.author = json.author;
}
if (!("description" in yaml) && ("description" in json)) {
yaml.description = json.description;
}
if (!("version" in yaml) && ("version" in json)) {
yaml.version = json.version;
}
}
return yaml;
});
}
function readYaml(yaml_file) {
return new Promise(function(resolve, reject) {
fs.readFile(yaml_file, "utf8", function(error, text) {
if (error) { return reject(new Error(`Failed to read ${yaml_file}: ${error.message}`)); }
try {
return resolve(yaml.load(text));
}
catch (error) {
return reject(new Error(`Failed to parse ${yaml_file}: ${error.message}`));
}
});
});
}
function readJson(json_file) {
return new Promise(function(resolve, reject) {
fs.readFile(json_file, "utf8", function(error, text) {
if (error && error.code === "ENOENT") { return resolve(undefined); }
else if (error) { return reject(new Error(`Failed to read ${json_file}: ${error.message}`)); }
try {
return resolve(JSON.parse(text));
}
catch (error) {
return reject(new Error(`Failed to parse ${json_file}: ${error.message}`));
}
});
});
}
function addShowCondition(setting) {
if (typeof setting === "string") { return; }
if (!["show_if", "hide_if"].some(d => d in setting)) { return; }
if (!setting.show_condition) { setting.show_condition = []; }
if (setting.show_if !== undefined) {
setting.show_condition.push({ type: "show", condition: setting.show_if });
delete setting.show_if;
}
else {
setting.show_condition.push({ type: "hide", condition: setting.hide_if });
delete setting.hide_if;
}
}
function addShowConditions(config) {
const settings = config.settings || [];
for (const setting of settings) {
addShowCondition(setting);
}
return config;
}
function qualifyNames(settings, namespace) {
for (let i = 0; i < settings.length; i++) {
const setting = settings[i];
if (typeof setting !== "object") { continue; }
if ("show_if" in setting) {
if (typeof setting.show_if === "string") {
setting.show_if = namespace + "." + setting.show_if;
}
else if (Array.isArray(setting.show_if)) {
const r = [];
for (let j = 0; j < setting.show_if.length; j++) {
const and_conditions = {};
for (const k in setting.show_if[j]) {
and_conditions[namespace + "." + k] = setting.show_if[j][k];
}
r.push(and_conditions);
}
setting.show_if = r;
}
else if (typeof setting.show_if === "object") {
const r = {};
for (const k in setting.show_if) {
r[namespace + "." + k] = setting.show_if[k];
}
setting.show_if = r;
}
// Else pass through unmodified: to support literal true/false values.
}
if ("hide_if" in setting) {
if (typeof setting.hide_if === "string") {
setting.hide_if = namespace + "." + setting.hide_if;
}
else if (Array.isArray(setting.hide_if)) {
const r = [];
for (let j = 0; j < setting.hide_if.length; j++) {
const and_conditions = {};
for (const k in setting.hide_if[j]) {
and_conditions[namespace + "." + k] = setting.hide_if[j][k];
}
r.push(and_conditions);
}
setting.hide_if = r;
}
else if (typeof setting.hide_if === "object") {
const r = {};
for (const k in setting.hide_if) {
r[namespace + "." + k] = setting.hide_if[k];
}
setting.hide_if = r;
}
// Else pass through unmodified: to support literal true/false values.
}
}
}
async function resolveImports(config, template_dir) {
const settings = config.settings;
if (!settings) { return config; }
for (let i = 0; i < settings.length; i++) {
const setting = settings[i];
if (typeof setting === "object" && "import" in setting) {
const imported_resolved = nodeResolve.sync(path.join(setting.import, "settings.yml"), { basedir: template_dir });
const imported_settings = await readYaml(imported_resolved);
qualifyNames(imported_settings, setting.property);
if ("overrides" in setting) {
setting.overrides.forEach(function(override) {
const use_tags = override.tag;
let settings_to_target = [];
if (use_tags) { settings_to_target = Array.isArray(override.tag) ? override.tag : [override.tag]; }
else { settings_to_target = Array.isArray(override.property) ? override.property : [override.property]; }
const method = override.method || "replace";
for (let target of settings_to_target) {
const settings_to_override = imported_settings.filter(function(setting) {
if (use_tags) {
if (!setting.tag) { return false; }
const setting_tags = Array.isArray(setting.tag) ? setting.tag : [setting.tag];
return setting_tags.includes(target);
}
return setting.property === target;
});
if (!settings_to_override.length) { continue; }
for (let name in override) {
for (const s of settings_to_override) {
if (name === "property" || name === "tag" || name === "method") { continue; }
if (method === "extend") {
if (["show_if", "hide_if"].includes(name) && typeof override[name] === "boolean") {
throw new Error(`Cannot extend a ${name} with Boolean value for property ${s.property}`);
}
let extendee = s[name];
if (extendee === undefined) {
if (name === "show_if" && s.hide_if !== undefined) {
throw new Error(`Cannot extend a show_if when hide_if defined for property ${s.property}`);
}
else if (name === "hide_if" && s.show_if !== undefined) {
throw new Error(`Cannot extend a hide_if when show_if defined for property ${s.property}`);
}
extendee = {};
}
if (name === "show_if" || name === "hide_if") {
let template_overrides = override[name];
let module_overrides = extendee;
if (!Array.isArray(template_overrides)) {
template_overrides = [template_overrides];
}
if (!Array.isArray(module_overrides)) {
module_overrides = [module_overrides];
}
let combined_conditions = [];
template_overrides.forEach(t => {
module_overrides.forEach(m => {
m = extendItem(m, t);
combined_conditions.push(m);
});
});
s[name] = combined_conditions;
}
else { s[name] = extendItem(extendee, override[name]); }
}
else {
s[name] = override[name];
if (name === "show_if" && s.hide_if) { delete s.hide_if; }
else if (name === "hide_if" && s.show_if) { delete s.show_if; }
}
}
}
}
});
}
for (let s of imported_settings) {
if (typeof s !== "object") { continue; }
s.property = setting.property + "." + s.property;
if (setting.show_condition) { s.show_condition = setting.show_condition.slice(); }
addShowCondition(s);
}
settings.splice.apply(settings, [i, 1].concat(imported_settings));
}
}
return config;
}
// Sets a default binding data_type of string in templates with both typed and untyped bindings, and return a post-publish warning message.
function checkDataTypes(config) {
const warnings = [];
if (!config.data) { return { config, warnings }; }
const all_bindings = config.data.filter(binding => typeof binding !== "string"); // filter out title and description strings
const any_bindings_are_typed = all_bindings.some(binding => binding.data_type);
if (any_bindings_are_typed) {
config.data.forEach(binding => {
if (typeof binding === "string") { return; }
if (!binding.data_type) {
binding.data_type = "string";
warnings.push(`Missing data_type for key ${binding.key} in dataset ${binding.dataset} - assuming "string"`);
}
});
}
return { config, warnings };
}
function readAndValidateConfig(template_dir) {
return readConfig(template_dir)
.then((config) => {
validateConfig(config, template_dir);
return config;
})
.then(config => addShowConditions(config))
.then(config => resolveImports(config, template_dir))
.then(config => checkDataTypes(config));
}
function changeVersionNumberInPackageJson(template_dir, change_function) {
if (!fs.existsSync(path.join(template_dir, "package.json"))) {
throw new Error("There is no version number in template.yml, and no package.json");
}
return readJson(path.join(template_dir, "package.json"))
.then(json => {
if (!json.version) {
throw new Error("There is no version number in template.yml or package.json");
}
const v = semver.parse(json.version);
change_function(v);
json.version = semver.join(v);
return writePackageJson(template_dir, json);
});
}
function changeVersionNumber(template_dir, change_function) {
return readYaml(path.join(template_dir, "template.yml"))
.then(yaml => {
if (!yaml.version) {
return changeVersionNumberInPackageJson(template_dir, change_function);
}
const v = semver.parse(yaml.version);
change_function(v);
yaml.version = semver.join(v);
return writeConfig(template_dir, yaml);
});
}
function incrementPrereleaseTag(template_dir) {
return changeVersionNumber(template_dir, v => {
if (v.length == 3) {
v[2] += 1;
v.push("prerelease", 1);
return;
}
if (typeof v[v.length - 1] === "number") {
v[v.length - 1] += 1;
}
else { v.push(1); }
});
}
function removePrereleaseTag(template_dir) {
return changeVersionNumber(template_dir, v => {
if (v.length == 3) {
throw new Error("There is no prerelease tag to remove.");
}
v.splice(3);
});
}
function incrementPatchVersion(template_dir) {
return changeVersionNumber(template_dir, v => {
v[2] += 1;
v.splice(3);
});
}
function installNodeModules(template_dir, node_env) {
if (fs.existsSync(path.join(template_dir, "package.json"))
&& !fs.existsSync(path.join(template_dir, "node_modules")))
{
if (fs.existsSync(path.join(template_dir, "package-lock.json"))) {
return runBuildCommand(template_dir, "npm ci", node_env);
}
return runBuildCommand(template_dir, "npm install", node_env);
}
else {
return Promise.resolve();
}
}
function buildRules(template_dir) {
return readConfig(template_dir)
.then((config) => {
const build_rules = [];
for (let key in config.build) {
build_rules.push(Object.assign({ key }, config.build[key]));
}
return build_rules;
});
}
function writeConfig(template_dir, config) {
return new Promise(function(resolve, reject) {
const config_file = path.join(template_dir, "template.yml");
fs.writeFile(config_file, yaml.safeDump(config, YAML_DUMP_OPTS), function(error) {
if (error) { return reject(new Error(`Failed to write ${config_file}: ${error.message}`)); }
return resolve();
});
});
}
function writePackageJson(template_dir, json) {
return new Promise(function(resolve, reject) {
const package_json_file = path.join(template_dir, "package.json");
fs.writeFile(package_json_file, JSON.stringify(json, null, 2) + "\n", function(error) {
if (error) { return reject(new Error(`Failed to write ${package_json_file}: ${error.message}`)); }
return resolve();
});
});
}
function checkTemplateVersion(template_dir) {
return readConfig(template_dir).then(config => {
const template_sdk_version = config.sdk_version;
if (!template_sdk_version) {
throw new Error("Template does not specify an sdk_version");
}
if (template_sdk_version < SDK_MAJOR_VERSION_COMPAT) {
throw new Error("This template was built for an older version of Flourish. Try running 'flourish upgrade'");
}
if (template_sdk_version > SDK_MAJOR_VERSION) {
throw new Error("This template was built for an newer version of Flourish than you have. Try updating the SDK.");
}
});
}
// Files and directories in a template that are treated specially by Flourish
const TEMPLATE_SPECIAL_FILES = new Set([
"index.html", "template.js", "template.yml", "thumbnail.png", "thumbnail.jpg", "README.md",
]);
const TEMPLATE_SPECIAL_DIRECTORIES = new Set([
"static", "data",
]);
const TEMPLATE_SPECIAL = new Set([
"index.html", "template.js", "template.yml", "thumbnail.png", "thumbnail.jpg", "README.md",
"static", "data",
]);
module.exports = {
checkTemplateVersion,
getSdkToken, setSdkToken, deleteSdkTokens, deleteSdkToken,
request,
runBuildCommand, buildTemplate,
readConfig, readAndValidateConfig, writeConfig, buildRules,
incrementPrereleaseTag, removePrereleaseTag, incrementPatchVersion,
TEMPLATE_SPECIAL_FILES, TEMPLATE_SPECIAL_DIRECTORIES, TEMPLATE_SPECIAL, SDK_VERSION, SDK_MAJOR_VERSION,
};