#!/bin/sh # Multicities: Neocities static site manager with multiple account support # Written in POSIX shell # Depends on mktemp, basename, realpath, sha1sum, curl and jq # Run without parameters to get help # Created by Luxferre in 2025, released into public domain REQCMD='curl -sSL' APIROOT='https://neocities.org/api' APICONF="${HOME}/.multicities.json" [ ! -f "$APICONF" ] && echo '{}' > $APICONF # Read secret string read_secret() { trap 'stty echo' EXIT stty -echo read -r "$@" stty echo trap - EXIT printf '\n' } # login [username]: convert password to token login() { user="$1" printf 'Password: ' read_secret npass token=$($REQCMD -u "$user:$npass" ${APIROOT}/key | jq -rj '.api_key') if [ "$token" != 'null' ]; then tmpfile=$(mktemp -t 'cities.XXXXXX') cat $APICONF | jq --arg u "$user" --arg t $token '. += {$u:$t}' > $tmpfile && mv $tmpfile $APICONF echo 'Login successful!' else echo 'Cannot login, please check the username and password!' exit 1 fi } # auth [username]: get auth header (empty if no username present) auth() { token="$(cat $APICONF | jq -rj --arg u "$1" '.[$u]')" if [ "$token" != 'null' ]; then printf 'Authorization: Bearer %s' "$token" else printf '' fi } # api call helper apiget() { $REQCMD -H "$AUTH_HDR" ${APIROOT}/$1 } # single file upload upload() { fpath="$(realpath "$1")" if [ -z "$2" ]; then localname="$(basename "$fpath")" else localname="$2" fi $REQCMD -H "$AUTH_HDR" -F "${localname}=@${fpath}" ${APIROOT}/upload } # site info cmd_info() { info_obj="$(apiget info | jq -r '.info | @text')" printf 'Sitename: %s\n' "$(echo $info_obj | jq -r '.sitename')" printf 'Views: %s\n' "$(echo $info_obj | jq -r '.views')" printf 'Hits: %s\n' "$(echo $info_obj | jq -r '.hits')" printf 'Created at: %s\n' "$(echo $info_obj | jq -r '.created_at')" printf 'Last updated at: %s\n' "$(echo $info_obj | jq -r '.last_updated')" printf 'Custom domain: %s\n' "$(echo $info_obj | jq -r '.domain // "None"')" printf 'Tags: %s\n' "$(echo $info_obj | jq -r '.tags | @csv')" } # site file list cmd_list() { apiget list | jq -r '.files[] | [if .is_directory then "dir" else "file" end, .size // 0, .path] | @tsv' } # upload individual files cmd_upload() { upload "$1" "$2" } # delete individual remote files cmd_delete() { echo -n 'Confirm file deletion (y to confirm)? '; read -r resp if [ "$resp" == "y" ]; then $REQCMD -H "$AUTH_HDR" -d "filenames[]=$1" ${APIROOT}/delete else echo 'Operation cancelled' fi } # upload the entire site directory cmd_sync() { echo "Getting file list..." # shape the path list with path as key and hash as value flist="$(apiget list | jq -r '[.files[] | [.path, .sha1_hash] | { (.[0]) : .[1] }] | add | @json')" projdir="$1" [ -z "$1" ] && projdir="$(pwd)" # iterate over the project directory find "$projdir" \( ! -path '*/.*' \) -type f -printf '%P\n' | while read -r f; do fullpath=$(realpath "${projdir}/$f") cursha="$(sha1sum -b "$fullpath" | cut -f 1 -d ' ')" prevsha="$(printf '%s' "$flist" | jq -r --arg p "$f" '.[$p]')" if [ "$prevsha" != "$cursha" ]; then # do the upload echo "Updating $f (prev: $prevsha, cur: $cursha)" upload "$fullpath" "$f" fi done echo "All files synced!" } # entry point if [ -z "$1" ]; then echo "Usage: cities.sh sitename command" echo "where command is one of:" echo "* login" echo "* info" echo "* list" echo "* upload file_path [remote_path]" echo "* delete remote_path (will ask for confirmation)" echo "* sync [local_dir]" else username="$1"; shift # fetch and shift out username cmdname="$1"; shift # fetch and shift out command name if [ "$cmdname" != "login" ]; then AUTH_HDR="$(auth $username)" if [ -z "$AUTH_HDR" ]; then login "$username" && AUTH_HDR="$(auth $username)" fi cmd_${cmdname} $@ || echo "Invalid command!" else login "$username" fi fi