Neocity Deploy Script
Neocities does not use FTP (at least, not in the classic sense), and instead uses a series of apis and libraries to upload files. I wasn't very happy with what was out there, so I made a light-weight-ish script to do this for me. Using Vuepress accelerated the need, as the dist
folder that the build process spits out requires I have an automated way to shunt all these files online.
You will need Node 22 installed in your system of choice. Your console should react if you type node
into it and pressing enter.
The script requires axios and form-data installed. I assume you are using a node-based web framework (i.e. Angular, React, Vue.)
# if you are not running a node project, replace --save-dev with -g to save globally instead
npm i --save-dev axios
npm i --save-dev form-data
The following is the script proper. I have a gist here if you like it there better.
import axios from 'axios';
import * as fs from 'fs';
import * as fspromise from 'fs/promises'
import * as readline from 'readline'
import FormData from 'form-data'
import execSync from 'child_process';
// current assumptions
const secretFile = 'secret'; // output secret key file. Add to your gitignore, rename to whatever you want.
const localArtifactRoot = 'src/.vuepress/dist/'; //relative directory to the folder containing your build artifacts.
const batchSize = 3; // how many files are uploaded at a time, higher number go faster but may look frozen.
const excludeFiles = [
'sitemap.xsl' // this is a paid feature for Neocities
]
var neocityUser = '';
var neocityPW = '';
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
var authKey = '';
while(authKey === ''){
if(!fs.existsSync(secretFile)){
console.log('No Key found');
console.log('==================');
neocityUser = await new Promise( resolve => {
rl.question('Please enter Neocities Username:', resolve);
});
// I want to blank this one day but eh.
neocityPW = await new Promise( resolve => {
rl.question('Please enter Neocities Password:', resolve);
});
neocityPW = encodeURI(neocityPW);
await axios.get( `https://${neocityUser}:${neocityPW}@neocities.org/api/key`)
.then( response => {
if(response.data?.result === 'success'){
authKey = response.data.api_key;
fs.writeFileSync(secretFile, authKey);
}
});
}
else{
console.log('Auth Key found');
authKey = fs.readFileSync(secretFile, {encoding:'ascii'});
//test the auth key because it might have expired
const neocities_temp = axios.create({
baseURL: 'https://neocities.org/api',
headers: {
Authorization: `Bearer ${authKey}`
}
});
var failed = false;
var result = await neocities_temp.get('/list')
.catch(reason => {
console.log(reason);
failed = true;
});
if( result?.status === 401 || result?.status === 403 || failed){
console.log('Nuking auth file, likely expired.')
fs.rmSync(secretFile);
authKey = '';
}
}
}
console.log(`Auth Key: ${authKey}`);
const neocities = axios.create({
baseURL: 'https://neocities.org/api',
headers: {
Authorization: `Bearer ${authKey}`
}
})
var executing = true;
while(executing){
console.log('What do you want to do?');
console.log('\t # 1. Update Build artifact');
console.log('\t # 2. Get Website Info');
console.log('\t # 3. Get a list of all files')
console.log('\t # 4. Upload to Neocities');
console.log('\t # 0. Quit');
var choice = await new Promise( resolve => {
rl.question('Choose from above:', resolve);
});
choice = choice.trim();
if(choice === '0'){
executing = false;
}
else if(choice === '1'){
if(fs.existsSync(localArtifactRoot)){
fs.rmSync(localArtifactRoot, {recursive: true});
console.log('dist folder nuked');
}
execSync.execSync('npm run docs:build');
}
else if(choice === '2'){
var info = await neocities.get('/info');
console.log(JSON.stringify(info.data));
}
else if(choice === '3'){
var info = await neocities.get('/list');
if(info.data?.result === 'success'){
var files = info.data.files.map(f =>{
return `${f?.size}\t | ${f?.updated_at} \t | ${f?.path} `
});
files.forEach(f => {
console.log(f);
});
}
}
else if(choice === '4'){
var distStructure = await fspromise.readdir(localArtifactRoot, {recursive: true, encoding: 'utf8', withFileTypes: true});
var filesToUpload = []
distStructure
.filter(d => !d.isDirectory())
.filter(f => excludeFiles.findIndex(ex => f.name.includes(ex)) < 0)
.forEach(d =>{
const normalizedFileName = `${d.parentPath.replace(/\/$/, '')}/${d.name}`
filesToUpload.push({
localFile: normalizedFileName,
remoteFile: normalizedFileName.replace(localArtifactRoot, '')
})
});
var batchNum = 0
for(let i = 0; i < filesToUpload.length; i += batchSize){
var batch = filesToUpload.slice(i, i + batchSize);
var formData = new FormData();
batch.forEach(bi => {
formData.append(bi.remoteFile, fs.createReadStream(bi.localFile));
});
await neocities.post('upload', formData, {
headers: {
...formData.getHeaders()
},
}).then( () => {
batchNum += 1;
console.log(`batch ${batchNum} of ${Math.ceil(filesToUpload.length/batchSize)} Success`);
});
}
console.log(`Number of files: ${distStructure.length}`);
}
else {
console.log(`Command not Recognized: ${choice}`);
}
}
// node scripts hang if the process doesn't kill itself.
process.exit(0);
The script will logon, then you will prompted for a choice. I've been using it a lot, seems to work fine.