uv also supports PEP 723 – Inline script metadata. That means you can write comments like this:
# /// script
# requires-python = "==3.13.*"
# dependencies = [
# "termcolor"
# ]
# ///
from termcolor import cprint
cprint("Green text!", "green")Then you can run your script with uv run:
You can also add the -q or --quiet flag to suppress the info about it reading the inline metadata and installing the dependencies for you.
You can take this one step further. You can set a shebang to automatically invoke your script with uv, making it a self-contained single-file Python program that automatically has its dependencies set up for it! The GNU coreutils env command allows you to specify the -S flag to split the argument and pass the flags to the command being invoked.
So we can write our shebang line like this:
#!/usr/bin/env -S uv run --script --quietWhen we execute our script, it does everything automatically:
There is one caveat to this approach: BusyBox. On most Linux systems you have GNU coreutils, and on macOS you have some other flavor of coreutils that supports the env -S flag, but the flag is not universal.
There's a simple enough workaround for this problem: install the GNU coreutils.
If you would like to avoid the GPL, you can use the MIT-licensed uutils-coreutils instead. It's a Rust reimplementation of GNU coreutils. It's a few megabytes larger, but is permissively licensed and written in a memory-safe language.
]]>import { exec } from "node:child_process"
import { promisify } from "node:util"
const execPromise = promisify(exec)
const username = prompt("Hello, what's your name?")
const banner = (await execPromise(`figlet "Welcome, ${username}" | lolcat -f`)).stdout // security vulnerability!
console.log(banner)This works as you might expect:
However, you might notice the bug when you run this with untrusted user input!
The constructed command was:
figlet "Welcome, "; ps; echo "" | lolcat -fThis kind of bug makes its way into production surprisingly often, although usually with trusted input. Even with trusted input, it can be a problem because you get strange bugs when your arguments contain spaces or something.
The obvious solution is to use execFile rather than exec and pass the arguments directly to the command with no shells parsing user input. It looks like this:
import { execFile } from "node:child_process"
import { promisify } from "node:util"
const execFilePromise = promisify(execFile)
const username = prompt("Hello, what's your name?")
// now we have to spawn the two processes and pipe the output of figlet into lolcat manually:
const lolcat = execFilePromise("lolcat", ["-f"])
const figlet = execFile("figlet", [`Welcome, ${username}`]).stdout.pipe(lolcat.child.stdin)
console.log((await lolcat).stdout)I think this is a rather ugly solution. An alternative is to continue using shells, but pass the inputs in as environment variables:
import { exec } from "node:child_process"
import { promisify } from "node:util"
const execPromise = promisify(exec)
const username = prompt("Hello, what's your name?")
const banner = (await execPromise('figlet "Welcome, $username" | lolcat -f', { env: { ...process.env, username } })).stdout
console.log(banner)We get the same, correct, result.
In JavaScript, we can take this one step further! Tagged templates allow you to write functions that receive the arguments to a template literal and return whatever you want. Here's an example implementation of safe interpolation:
import { exec as exec } from "child_process"
import { promisify } from "util"
const execPromise = promisify(exec)
async function shell(fragments: TemplateStringsArray, ...values: unknown[]) {
const env = { ...process.env }
const command = fragments.reduce((constructedCommand, fragment, i) => {
if (i < values.length) {
const varName = `_val${i + 1}`
env[varName] = `${values[i]}`
return constructedCommand + fragment + `$\{${varName}}`
}
return constructedCommand + fragment
}, "")
return await execPromise(command, { env })
}
const username = prompt("Hello, what's your name?")
const banner = (await shell`figlet "Welcome, ${username}" | lolcat -f`).stdout
console.log(banner)This allows you to safely do string interpolation with untrusted user input on shell commands! There are still plenty of footguns depending on the command you're using, like how the env command will start parsing out args if you start the first arg with -S . In general, be careful that the command you're passing untrusted user input to doesn't do unexpected things when given maliciously-crafted input.
Now how about in other languages? In most cases, the best you can do is the environment variable method. Here's an example in Python:
import subprocess
import os
def shell(command, **values):
return subprocess.run(
command,
shell=True,
check=True,
text=True,
capture_output=True,
env=os.environ | values,
)
username = input("Hello, what's your name? ")
banner = shell('figlet "Welcome, $username" | lolcat -f', username=username)
print(banner.stdout)There is a proposal, PEP 750, that would enable something like:
shell(t'figlet "Welcome, {username}" | lolcat -f') It is expected to land in Python 3.14.
Swift, however, is the only language other than JavaScript I have identified that has the equivalent feature today. It allows you to write structs extending ExpressibleByStringInterpolation in a similar fashion to JavaScript's tagged templates.
I had not written a line of swift since I was eleven years old, and I didn't want to go learn Swift for a quick proof-of-concept, so this code is largely LLM-assisted.
import Foundation
struct Shell: ExpressibleByStringInterpolation {
private var command: String
private var env: [String: String]
var result: String
struct Interpolation: StringInterpolationProtocol {
var command = ""
var env: [String: String] = [:]
private var argCount = 0
init(literalCapacity: Int, interpolationCount: Int) {}
mutating func appendLiteral(_ literal: String) {
command += literal
}
mutating func appendInterpolation(_ value: Any) {
argCount += 1
let varName = "_val\(argCount)"
env[varName] = "\(value)"
command += "${\(varName)}"
}
}
init(stringLiteral value: String) {
command = value
env = [:]
// We can't use the run() function until all of our properties are initialized
// so we need to set result to "" first.
result = ""
// I'm not totally sure how this try? syntax works, the LLM generated it
// Presumably, it does run() and returns null if it fails, then we optional-chain to "" if it fails
result = (try? run()) ?? ""
}
init(stringInterpolation: Interpolation) {
command = stringInterpolation.command
env = stringInterpolation.env
result = ""
result = (try? run()) ?? ""
}
private func run() throws -> String {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/sh")
process.arguments = ["-c", command]
var environment = ProcessInfo.processInfo.environment
for (key, value) in env {
environment[key] = value
}
process.environment = environment
// Combining stdout and stderr is different
// than what I did in the Python and JS implementations.
// This function is purely LLM-generated (excluding this comment).
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
return String(data: data, encoding: .utf8) ?? ""
}
}
print("What is your name?", terminator: " ")
let username = readLine()
// Swift doesn't have single-quoted strings, so we need to escape the double quotes
// in the figlet command.
let banner: Shell = "figlet \"Welcome, \(username!)\" | lolcat -f"
print(banner.result)I wanted to see if it was possible to make shell(f'figlet "Welcome, {username}" | lolcat -f') not vulnerable to shell injection. Nobody should ever use this, but I managed to bludgeon it into doing what I wanted. It uses a decorator function that grabs the function's source code, manipulates it with regexes (I suppose you could use the AST for this, but editing code with regex makes the fun even more fun – nobody is putting this in production hopefully), and executes the newly updated code.
import inspect
import os
import re
import subprocess
import uuid
def with_shell(f):
def wrapper(*args, **kwargs):
# Horrifying hack within a horrifying hack:
# Delete the first line of the function source code,
# which is the decorator itself.
# This is necessary to avoid infinite recursion.
# It will break if there's a decorator above this one.
function_source = "\n".join(inspect.getsource(f).splitlines()[1::])
pattern = r'shell\(f(["\']{1,3})(.*?)\1\)'
# We could replace all of the interpolated values with "?" or something,
# but what if the command itself contains a "?"?
# Instead, we generate an unpredictable placeholder.
placeholder = str(uuid.uuid4())
def replace_shell_invocation(match):
cmd = match.group(2)
placeholders = []
def replace_braces(match):
placeholders.append(match.group(1))
return placeholder
cmd_no_braces = re.sub(r"\{(.+?)\}", replace_braces, cmd)
placeholders_str = ", ".join(placeholders)
if placeholders_str:
return f'shell.internal({repr(cmd_no_braces)}, "{placeholder}", {placeholders_str})'
return f'shell.internal({repr(cmd_no_braces)}, "{placeholder}")'
new_code = re.sub(
pattern, replace_shell_invocation, function_source, flags=re.DOTALL
)
exec(compile(new_code, f.__code__.co_filename, "exec"), f.__globals__)
# We can't use f(*args, **kwargs) because it would call the original function,
# so we need to eval its name to get a reference to the newly-generated function
# from the exec.
eval(f.__name__)(*args, **kwargs)
return wrapper
def shell(command: str) -> str:
"""
Run a shell command and return the output. Prevents shell injection.
Args:
command: The shell command to run
Returns:
The output of the shell command
"""
raise NotImplementedError("Make sure you're using the with_shell decorator")
def shell_internal(command, placeholder, *args):
i = 0
env = {}
while placeholder in command:
var_name = f"_val{i}"
env[var_name] = args[i]
command = command.replace(placeholder, f"${{{var_name}}}", 1)
i += 1
return subprocess.run(
command,
shell=True,
check=True,
text=True,
capture_output=True,
env=os.environ | env,
)
shell.internal = shell_internal
@with_shell
def main():
username = input("Hello, what's your name? ")
banner = shell(f'figlet "Welcome, {username}" | lolcat -f')
print(banner.stdout)
main()I use Actual to manage my budgets. I've used a spreadsheet in the past but I prefer to use a dedicated self-hosted application for it. It looks like this:
If there's one thing I like to write code for, it's automating away unnecessary tedious work. Actual has some support for “Goal Templates” that allow you to automatically allocate your budgets with tags in the category's note like #template $50 to allocate $50 every month. I have more advanced goals for my budgeting automation:
Goal templates cannot accomplish that. So, I wrote this!
// My budgeting app, Actual (https://actualbudget.org) has "Goal Templates" but I wanted more flexibility. I wrote this script that uses the Actual API to apply some custom rules to my budget:
// 1. Essentials:
// * Set aside money for bills, and save up for bills that are not monthly
// * Set aside money for tithing
// * Set aside money for an emergency fund
// * Set aside money for gas
// 2. Divide the remainder of my income into categories, rounding down to the nearest cent. The categories are as follows:
// * 30% for general
// * 20% for social
// * 10% for gifts
// 3. Save the rest in a savings category, including any rounding errors from dividing the income.
import api from '@actual-app/api'
// dotenv is used for loading the password for Actual from a .env file.
import { config as dotenv } from 'dotenv'
dotenv()
// fs is used for resetting the cache prior to each run.
import fs from 'fs/promises'
// Some configuration is written as constants.
const DATA_DIR = './cache'
const SERVER_URL = 'https://actual.ts.wingysam.xyz'
const BUDGET_FILE_ID = 'budget-uuid-here'
const CURRENCY_UNITS = 100 // https://actualbudget.org/docs/api/reference#primitives
// These numbers are dummy data
const TITHE_PORTION = 0.10
const EMERGENCY = 500
const GAS = 200
const INCOME_DISTRIBUTION = [ // [fraction of income, category id]
[0.30, 'General'],
[0.20, 'Social'],
[0.10, 'Gifts']
]
const BILL_AMOUNTS_AND_PERIODS = [ // [monthly cost, months to save up = 1]
[100, 3], // Car Insurance, quarterly
[30], // Haircut
[50], // Phone Service
[10], // Phone Payment
[30], // Gym monthly
[5, 12] // Gym annual
]
// The password to Actual and the month to apply the budgeting rules to are taken from env.
const { ACTUAL_PASSWORD, MONTH } = process.env
if (!ACTUAL_PASSWORD) throw new Error('Set ACTUAL_PASSWORD.')
if (!MONTH) throw new Error('Set MONTH.')
// The budget object and income are stored globally so they don't need to be recomputed or passed around.
let budget, income
main().catch(error => {
log('Fatal Error:', error)
process.exit(1)
})
async function main () {
await resetCache()
await api.init({
dataDir: DATA_DIR,
serverURL: SERVER_URL,
password: ACTUAL_PASSWORD
})
await api.downloadBudget(BUDGET_FILE_ID)
budget = await api.getBudgetMonth(MONTH)
income = getCategory('Income').received
await applyBudgetRules()
await printBalances()
await api.shutdown()
}
// Caching the budget saves a fraction of a second and introduces more complexity and potential for bugs than I'm willing to tolerate.
// As such, I wipe it every time the script runs.
async function resetCache () {
await fs.rm(DATA_DIR, {
recursive: true,
force: true
})
await fs.mkdir(DATA_DIR)
}
async function applyBudgetRules() {
// These don't depend on each other, so they can be done in parallel.
await Promise.all([
setAsideBills(),
setAsideTithe(),
setAsideEmergency(),
setAsideGas(),
clearSavingsBudget()
])
// These depend on the previous steps, so they must be done in sequence.
await divideIncome()
await saveRemainder()
}
async function clearSavingsBudget() {
await setBudgetAmount('Savings', 0)
}
// I want to budget for all of my bills every month.
// Some of my bills are quarterly or annual, so I save the cost per month of those bills.
// I don't want it to set the balance higher than (monthly cost * months to save up).
async function setAsideBills() {
let billsPerMonth = 0
let saveUpGoal = 0
for (const [perMonth, monthsToSave = 1] of BILL_AMOUNTS_AND_PERIODS) {
billsPerMonth += Math.round(perMonth * CURRENCY_UNITS)
saveUpGoal += Math.round(perMonth * monthsToSave * CURRENCY_UNITS)
}
await addToCategoryBalance('Bills', billsPerMonth, saveUpGoal)
}
async function setAsideTithe() {
await addToCategoryBalance('Tithe', Math.ceil(income * TITHE_PORTION))
}
async function setAsideEmergency() {
await addToCategoryBalance('Emergency', EMERGENCY * CURRENCY_UNITS, EMERGENCY * CURRENCY_UNITS)
}
async function setAsideGas() {
await addToCategoryBalance('Gas', GAS * CURRENCY_UNITS, GAS * CURRENCY_UNITS)
}
async function divideIncome() {
for (const [_, categoryName] of INCOME_DISTRIBUTION) {
await setBudgetAmount(categoryName, 0)
}
const remainder = budget.toBudget
for (const [fraction, categoryName] of INCOME_DISTRIBUTION) {
const amountToBudget = Math.floor(remainder * fraction)
await addToCategoryBalance(categoryName, Math.max(0, amountToBudget))
}
}
async function saveRemainder() {
await addToCategoryBudget('Savings', budget.toBudget)
}
async function addToCategoryBalance(categoryName, amountToAllocate, maxToSaveUpTo = Number.MAX_SAFE_INTEGER) {
const category = getCategory(categoryName)
// I don't want this month's spending so far to impact the budget.
const balanceAsOfEndOfLastMonth = category.balance - category.budgeted - category.spent
const newBalance = Math.min(balanceAsOfEndOfLastMonth + amountToAllocate, maxToSaveUpTo)
const newBudget = newBalance - balanceAsOfEndOfLastMonth
await setBudgetAmount(categoryName, newBudget)
}
async function addToCategoryBudget(categoryName, amount) {
const category = getCategory(categoryName)
const newBudget = category.budgeted + amount
await setBudgetAmount(categoryName, newBudget)
}
async function setBudgetAmount(categoryName, newBudget) {
const category = getCategory(categoryName)
await api.setBudgetAmount(MONTH, category.id, newBudget)
// The API call doesn't update the budget object, so we need to update it manually.
const oldBudget = category.budgeted
category.budgeted = newBudget
budget.toBudget -= newBudget - oldBudget
category.balance += newBudget - oldBudget
}
function getCategory(categoryName) {
const categories = budget.categoryGroups.flatMap(categoryGroup => categoryGroup.categories)
const matches = categories.filter(category => category.name === categoryName)
if (matches.length === 0) throw new Error(`Category not found: ${categoryName}`)
if (matches.length > 1) throw new Error(`Duplicate categories found: ${categoryName}`)
return matches[0]
}
async function printBalances() {
log('New Balances:')
for (const categoryGroup of budget.categoryGroups) {
for (const category of categoryGroup.categories) {
if (category.is_income) continue
log(`${category.name}: ${formatCents(category.balance)}`)
}
}
}
// The script that invokes this program filters to lines that match /^> /. This is because Actual's SDK has some console.log statements that can't be turned off.
// I might open a PR to centralize the logging in the SDK at some point.
function log(...arg) {
console.log('>', ...arg)
}
// The cents function turns a number of cents into a string formatted as a dollar amount.
// For example, 5000 cents becomes "$50.00". -5000 cents becomes "-$50.00".
function formatCents(cents) {
const formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
});
return formatter.format(cents / 100);
}So now, I can automatically do my budgeting for the month like this:
MONTH=2024-04 node src/index.jsBut that's not good enough! So I used iOS Shortcuts with the “Run script over SSH” action:
So now, I open Siri and tell it to “Allocate Budget” and it automatically does all of my budgeting for the month in a few seconds! It also works from my Apple Watch. Here's an example with $2,000 of income:
Overall, I'm incredibly happy with what I've built. I can tell it to recompute the budget at any time and it tells me what I have available in each of my categories to spend.
]]>This was the first iteration of my site, based on this Jekyll theme. In my opinion, this is as good as I could have hoped for as a twelve-year-old.
This was the second iteration of my site, deployed on 2018-10-15. I was 13 at the time. The site used Jekyll again, this time with Hyde. I don't have much to say about the site but it worked well enough until it was replaced.
The third iteration of my site, deployed on 2020-05-31, was a single-page app written in React and Arwes. I was 14 at the time. The general design of the site was carried through to the new site.
This is the latest iteration of my site, created on 2022-12-15. At the time of writing, I am 17. The design has largely been inspired by iteration 3, but I'm no longer using a template other than what was created for me by SvelteKit. Posts are now rendered on the server rather than the client. JavaScript is no longer required to visit my site. For this iteration, I have moved from wingysam.xyz to samwing.dev.
]]>| Service | Dropped? | Alternative | Notes |
|---|---|---|---|
Cardboard | ✅ | Not a service, just a standard | |
Connected Home | ❌ | This would be replacable with Mycroft but I can't afford to buy new speakers. I'm not eligible to get a job given my age. | |
Gmail | ❌ | Unknown | I use forwardemail.net to forward from wingysam.xyz, but I still haven't migrated everything there yet. Once I have things migrated, I still won't be able to switch to a private provider because I don't have income (I'm 15 and can't get a job until I'm 16). |
Google Cast | ❌ | Unknown | Can't get rid of this until I get rid of Connected Home. |
Google Cloud Print | ✅ | AirPrint | Cloud Print shut down. Thanks Google. |
Google Fonts | ✅ | .woff2 files | Not related to my account, so I don't use it. |
Maps | ✅ | OpenStreetMap/Apple Maps | I use OpenStreetMap and Apple maps now. |
Search | ✅ | I use DDG. | |
Sheets | ❌ | Impossible to switch from. Other people use Google sheets and I have to be able to access those unfortunately. Maybe I could use a dummy account? | |
Slides | ❌ | WPS Presentation | Previously LibreOffice, was recommended WPS because it's better than LibreOffice for presentations. |
Translate | ✅ | I use DeepL whenever I need to translate now. | |
Voice | ❌ | No free VoIP services that I can find. | |
YouTube | ❌ | Nothing is viable to replace YouTube for me yet. |
Unknown Template
It gave me an error. I looked in the pull requests and found #1. It updates the version of a few dependencies and it works again.
I executed it. The css in question was bulma.min.css.
cat bulma.min.css | scopify-cli '#todorender'However, I made a mistake. This tool does not output to stdout. It writes to the current directory. It scopified almost every CSS file in my home directory until I noticed what was wrong and ^C'd it.
Now I needed to fix this mess. I have hourly backups with borgbackup, but I needed to use them and only extract css files. I looked up the documentation and spent ~20 minutes trying to make --exclude work. I then found --pattern and spent another 10 minutes trying to make that work. Every time it wouldn't match anything. I eventually found that I needed to use --exclude 're:something'.
I ended up with the following command:
borg extract --dry-run --list /data/Backups/borg/home/ssd::2020-11-06T20:00:02 'home/wingy/Code' --exclude 're:^((?!\.css).)*$'I tested that it outputted the correct files and removed the --dry-run and did it again. Everything ended up being fine.
Lesson: RAID is not a backup. Learn to restore backups.
]]>They did understand Brython enough to do pretty much anything on any page, but didn't know how to inject it. I built a userscript, then they improved the style a bit.
Here's the result:
brython_loader.js
// ==UserScript== // @name Brython Loader by Wingy // @version 2020-08-25-0158AM-UTC-5-Wingy // @run-at document-end // @grant GM.xmlHttpRequest // ==/UserScript== const HOME_SWEET_HOME = 'http://127.0.0.1:18555' function loadScript(url) { const script = document.createElement('script') script.src = url document.head.appendChild(script) } GM.xmlHttpRequest({ method: 'GET', url: `${HOME_SWEET_HOME}/entrypoint`, onload: function(response) { // Placeholder for the entrypoint script const script = document.createElement('script') script.textContent = response.responseText script.type = 'text/python' document.body.appendChild(script) const brythonLoader = document.createElement('script') brythonLoader.textContent = 'brython()' setTimeout(() => document.body.appendChild(brythonLoader), 0) // runs on next js tick } }) loadScript(`${HOME_SWEET_HOME}/brython/brython.js`) loadScript(`${HOME_SWEET_HOME}/brython/brython_stdlib.js`)
For the past few days, I've been writing in a private journal and enjoying it a lot more. I gave myself a lot of freedom in the private journal. I'm trying to write between one and infinity words every day.
I'll probably still post some things here, but they won't be #100DaysToOffload.
]]>I pressed Caps Lock and it returned to normal, and I thought everything was fine, then I tried Alt+Tab and it went backwards. I needed a solution, so tried to use Windows + 1 to go to my first app in the task bar, in this case Firefox.
When I pressed Control + T, it opened a new window instead of a new tab. Then I was searching for STUCK SHIFT KEY WINDOWS 10 because I had turned off capslock at this point.
I tried to ask for help in Discord, but I was in a game channel so I needed to go to a different server. I kept clicking a different server but it wasn't switching. After a minute I gave up and tried to go to Firefox. I then realized that I had 10 instances of that server in a Firefox window.
After typing out a message on Discord in Firefox, I pressed enter, and it dropped to a new line instead of sending. I then realized something.
Have you tried turning it off and on again?
Oh wait I forgot that part. Reboot. Windows advanced options because rebooting while holding shift opens advanced options. Shut down. Press power button to turn it on again.
🎉
]]>After going through some options for analytics, I decided to use none. My reason wasn't for technical reasons or moral reasons, but because I just wanted to write.
I wanted to write to you. I have no way of knowing that you are reading this, except that someone will probably read this, and if you are reading this, you are reading this.
I like that. If it were different, it would have a negative effect.
If I had few readers, it would discourage me and I would find it difficult to continue writing.
If I had many readers, I would potentially think I was an authority on something when I'm actually not.
]]>I've decided to go through the entire list of Google services, and find which ones I use.
| Service | Using | Alternative | Notes |
|---|---|---|---|
Android Auto | ❌ | ||
Android OS | ❌ | ||
Calendar | ❌ | Trilium todo | |
Cardboard | ✅ | Not a service, just a standard | |
Chrome | ❌ | I use Chrome to test apps once I've built them, but I mostly use Firefox. Brave, Vivaldi, etc. Do not count. They are built on Blink. | |
Chrome Web Store | ❌ | ||
Chromebook | ❌ | Custom built PC | |
Chromecast | ❌ | ||
Connected Home | ✅ | Mycroft is a good alternative, but can't be put on my existing speakers. I can't afford to buy Mycroft speakers or Raspberry Pi speakers everywhere. | |
Contacts | ❌ | ||
Daydream View | ❌ | ||
Docs | ❌ | ||
Drawings | ❌ | ||
Drive | ❌ | Samba+Wireguard | |
Earth | ❌ | ||
Finance | ❌ | ||
Forms | ❌ | ||
Gboard | ❌ | iOS Keyboard | |
Gmail | ✅ | Unknown | I use forwardemail.net to forward from wingysam.xyz, but I haven't migrated everything there yet. I'd like to use a different provider too, ideally. |
Google Alerts | ❌ | ||
Google Cast | ✅ | Unknown | I use this to send messages from Home Assistant to Google Home Minis. Also used for Spotify. |
Google Chat | ❌ | ||
Google Classroom | ❌ | ||
Google Cloud Print | ✅ | AirPrint | I use AirPrint when I don't use a Chromebook. However, Google Cloud Print is shutting down at the end of this year in 6 months, and Chrome OS now has CUPS, which my AirPrint server already has. |
Google Duo | ❌ | ||
Google Expeditions | ❌ | ||
Google Fi | ❌ | ||
Google Fit | ❌ | ||
Google Flights | ❌ | I've never flown, despite my name (Wingy). | |
Google Fonts | ✅ | .woff2 files | I try to self-host Google Fonts when I can. |
Google Groups | ❌ | Discourse | |
Google Input Tools | ❌ | I don't quite understand what this does. It lets you type other languages to the web? I just don't see what the web does with them after that. Seems likely to end up in the graveyard. | |
Google Meet | ❌ | ||
Google One | ❌ | Technically every Google account has it, but I haven't purchased a plan with it. | |
Google Pay | ❌ | Apple Pay | |
Google Photos | ❌ | Photos.app | Would like a web-based alternative, because currently I use Photos.app on my mom's laptop since I use Windows. Would replace Windows with Linux if it supported anticheat in games. |
Google Play | ❌ | App Store | |
Google Play Books | ❌ | Books on iOS | |
Google Play Games | ❌ | App Store | |
Google Play Movies & TV | ❌ | I mostly don't watch movies or TV. I generally prefer games like Celeste or Portal if I want to relax. | |
Google Play Music | ❌ | Spotify | |
Google Shopping | ❌ | Amazon | I'm a kid, so I mostly don't shop, except for buying computer parts mostly. |
Google Store | ❌ | Walmart | The Google Home Minis that I have were from Walmart, except for one that I got on Google Express from Spotify. |
Google Street View | ❌ | ||
Hangouts | ❌ | iMessage/SMS | |
Keep | ❌ | ||
Maps | ✅ | OpenStreetMap/Apple Maps | I mostly don't use maps, but occasionally do from search results. |
Messages | ❌ | Messages (Apple) | |
Nest Wifi | ❌ | Netgear Router | |
News | ❌ | Hacker News, RSS | No news is good news I guess? |
Pixel | ❌ | iPhone 6S | |
Pixelbook Go | ❌ | Old ASUS notebook running Linux | |
Play Protect | ❌ | Apple's ridiculous process | |
Scholar | ❌ | ||
Search | ✅ | ||
Sheets | ✅ | I use Sheets for collaboration, but not Docs. I'm weird I guess? | |
Sites | ❌ | ||
Slides | ❌ | LibreOffice Impress | |
Stadia | ❌ | My PC | |
Tilt Brush | ❌ | ||
Translate | ✅ | ||
Voice | ✅ | ||
Waze | ❌ | ||
Wear OS by Google | ❌ | ||
YouTube | ✅ | This is going to be impossible to get rid of for me for now unfortunately. PeerTube doesn't have critical mass and nobody is creating content for it. Maybe I could use Invidious though? I'd lose the recommendations AI. | |
YouTube Kids | ❌ | ||
YouTube Music | ❌ | ||
YouTube TV | ❌ |
Now that I've identified which services I use, I can work on getting rid of them in a followup.
]]>sudo dd if=/dev/sda status=progress | ssh [email protected] "dd of=/data/mbpssd.img". I did that from an Ubuntu 20.04 live USB. The command runs dd as root to read /dev/sda, then pipes that to ssh, which reads from stdin and outputs to disk.Once I did that, I used losetup -fP mbpssd.img to mount it, then mounted the partitions to make sure it worked. There could be some corruption that I didn't notice, but SSH is TCP and the encryption would fail if there was a corruption right?
It manages several installs of the game. You could want an install with the vanilla game, a game with tons of cheats with QMods, a game with not very cheaty mods, and a game with only mods that make it harder.
There's also one big problem with mods in Subnautica. QMods, the platform used by literally every mod except one, is completely incompatible with that one. And the incompatibility is Nitrox.
Nitrox is a MULTIPLAYER!! mod for Subnautica. I would give up all of my mods for Nitrox because it's so much fun. However, I don't have to because I made Submaniac.
Submaniac's menu defaults to the currently loaded save file. I switched to Nitrox and pressed enter, then it moved my old save into its directory, then moved my Nitrox save into the save directory. It then launched the game.
I might add a mod manager or game creator tool in the future, currently I have to create installs with Windows Explorer.
I'm going to go play Subnautica now.
]]>When I put it in, it did the flying outward thing which I expected, then I tried to put the motherboard standoff in but couldn't get it because it was tiny, so I ended up using a tiny torx screwdriver and friction inside the screw hole as a head to put the standoff in.
I tried to push the SSD to the standoff and put the screw in, but I had put the standoff in the second hole, not the third which I needed. I moved the standoff with the same process, then realized that I had lost the screw. After a minute of panic I found the screw behind my toolkit.
Once I had the SSD in, I booted up Clonezilla and tried to copy the old SATA DRAM-less SSD to my new NVMe with DRAM SSD, and it told me that I can't have both GPT and MBR, and it gave me a command to use, but I wasn't comfortable just blindly pasting it in so I looked it up.
There was a SuperUser question about it and an answer with an explanation as to what everything did. I wiped out GPT and kept MBR, then cloned the drive. It transferred at around 10 GB per minute.
When it was cloned, I pulled the old SSD and kept the new one in the system. I pressed the power button, or reset button in my case because my power button is broken. The Windows logo showed up and it boote… wait…
I was met with a BSOD that said INACCESSIBLE BOOT DEVICE.
I found an article that had instructions to fix it, but I had to adapt the command that they used to my case.
This was their command:
Now, exit out of diskpart and run the following command: bcdboot c:\windows /s e: /f UEFI
I couldn't get it to work in the advanced options command prompt, and eventually had to use Safe Mode with Command Prompt.
I ended up with (after some trial and error with thinking that my C drive had moved to E: but it hadn't) bcdboot c:\windows.
Once I had done that, I rebooted and Windows worked perfectly!
To install the Wireguard kernel module, I needed to reboot since I couldn't find it with modprobe. I checked my backups and rebooted.
root@tundra:~# reboot
Connection to wingysam.xyz closed by remote host.
Connection to wingysam.xyz closed.Then I used while true; do ssh root@vps; done to automatically log back in on startup. I also checked my Scaleway dashboard. The dashboard said that tundra was still running. I got logged back in.
I checked wingysam.xyz and it was still up. I checked Abide Within and it was still up. Church Alive was up too.
Wireguard, however, was not.
dmesg had nothing but ufw state changes.
I checked /var/log/apt/term.log and…
dpkg: warning: version '*-*' has bad syntax: version number does not start with digit It is likely that 4.4.122-mainline-rev1 belongs to a chroot's host Module build for the currently running kernel was skipped since the kernel source for this kernel does not seem to be installed.It seems that KVM/Qemu is in docker.
And since I can't get headers for my kernel, there's nothing I can do :(
Basically I know all of this because my friend Snekk helped me through all of it :)
I didn't publish this post at the time of writing this second part but I realized that Scaleway has special instructions for this!
Installing and Configuring WireGuard® on Linux as a VPN server and Local boot option on Development and General Purpose Virtual Cloud Instances show how to get kernel modules working on Scaleway servers! I enabled it and rebooted.
After an hour of trying to get it to work, I noticed:
To switch to local boot on an older instance, you need to re-create it with one of our new local boot compatible images. All newly created Development or General Purpose virtual cloud Instances will use a local kernel by default. Previously created instance do not support local boot and can’t use this feature!
And I can't just nuke the server without setting up borgbackup and restoring, which would take a long time. So I can't set up Wireguard.
Also the new server type has:
Then I found this script on Scaleway's GitHub repository:
# Determine versions
arch="$(uname -m)"
release="$(uname -r)"
upstream="${release%%-*}"
local="${release#*-}"
# Get kernel sources
mkdir -p /usr/src
wget -O "/usr/src/linux-${upstream}.tar.xz" "https://cdn.kernel.org/pub/linux/kernel/v4.x/linux-${upstream}.tar.xz"
tar xf "/usr/src/linux-${upstream}.tar.xz" -C /usr/src/
ln -fns "/usr/src/linux-${upstream}" /usr/src/linux
ln -fns "/usr/src/linux-${upstream}" "/lib/modules/${release}/build"
# Prepare kernel
zcat /proc/config.gz > /usr/src/linux/.config
printf 'CONFIG_LOCALVERSION="%s"\nCONFIG_CROSS_COMPILE=""\n' "${local:+-$local}" >> /usr/src/linux/.config
wget -O /usr/src/linux/Module.symvers "http://mirror.scaleway.com/kernel/${arch}/${release}/Module.symvers"
apt-get install -y libssl-dev # adapt to your package manager
make -C /usr/src/linux prepare modules_prepareI ran that, Levi told me to dpkg-reconfigure wireguard-dkms and then Wireguard worked!
Until now. Yesterday I took a look at my dockerfile and found that the problem my months old dockerfile had was postgresql. I had been trying to expect an out of container one. I tried to get that method working but I couldn't. I decided to just put postgres in the container with the application even though it's considered bad practice.
I got an error that the role “root” did not exist in postgres. The image also didn't have sudo so I had to put it in the image. I probably could apt-get install sudo && sudo -u postgres … && apt-get remove sudo to work though.
I also eventually realized that I can just su postgres -c … instead of installing then removing sudo.
I got postgres running, and now the app throws a 500 error on startup. I'm giving up for today because I've been working on it for ~3 hours. I'll probably post a follow-up soon.
Update 2021-02-18: I never got it working, but someone else did.
]]>100 Days to Offload
I've decided to try doing #100DaysToOffload. “Just. Write.” I don't know if I'll reach my goal, or give up after a week or something. What I do know is that I'm starting now and that I intend to write something else soon!
Bitwarden used to have this color scheme:
And now it has this:
Personally I prefer the old one.
]]>My implementation looks like this:
This is the code that I have for making a new post (always up to date, embedded from real script):
Manage
// Run this script to create a new post. const POSTS_FOLDER = 'E0sJWbschXAm' ;(async () => { const note = await api.createNewNote({ parentNoteId: POSTS_FOLDER, type: 'text', title: 'Untitled Post', content: '' }) await note.note.setAttribute('label', 'draft', 'true') })()
The External API is a folder with some HTTP APIs. The site you're on calls these APIs. The first one is Posts:
Posts
// This script is the API for listing posts const FOLDER_CONTAINING_POSTS_ID = 'E0sJWbschXAm' // express.js API provided by Trilium const { req, res } = api ;(async () => { // Modules - have to be in an async context to use import() // thenby: my favorite micro-library, helps sorting things const { firstBy } = (await import('/home/node/trilium-data/npm/node_modules/thenby/thenBy.module.js')).default if (req.method == 'GET') { const root = await api.getNote(FOLDER_CONTAINING_POSTS_ID) const children = await root.getChildNotes() // Simultaneously process every note, deciding if it should be in the post list const filtered = ( await Promise.all( children.map(async (note) => { // Drafts are filtered from the post list, but stil accessible from the Post endpoint const draft = await note.getAttribute('label', 'draft') if (draft && draft.value === 'true') return null // The date of the post should be included in the response note.date = await note.getAttribute('label', 'date') note.date = (note.date && note.date.value) || note.utcDateCreated // The tag attribute can be specified multiple times note.tags = await note.getAttributes('label', 'tag') if (!note.tags) note.tags = [] note.tags = note.tags.map((tag) => tag.value) // The script for creating a new post (Manage) should not be included in the post index // This is a more generic way to allow children in the root to not be listed const article = await note.getAttribute('label', 'article') if (!article) return note if (article.value === 'false') return null return note }) ) ) .filter((noteOrNull) => noteOrNull) .sort(firstBy('date', -1)) // This header is required to tell browsers that my website is allowed to access the response. // Documentation on CORS: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS res.header('Access-Control-Allow-Origin', '*') res.send( filtered.map((note) => { return { id: note.noteId, title: note.title, date: note.date, tags: note.tags } }) ) } else { // This route should only be accessible by GET. res.sendStatus(400) } })()
It returns a list of posts that are available to read (except drafts).
The second one is Post, and that returns the content of a post. The code is:
Post
const { req, res } = api ;(async () => { if (req.method == 'GET') { const target = req.query.id if (!target) return res.sendStatus(404) const result = await getContent(target) const public_fileserve = await result.note.getAttribute( 'label', 'public_fileserve' ) if (!public_fileserve) return res.sendStatus(403) if (public_fileserve.value !== 'true') return res.sendStatus(403) result.note.date = await result.note.getAttribute('label', 'date') result.note.tags = await result.note.getAttributes('label', 'tag') result.note.fullwidth = await result.note.getAttribute('label', 'fullwidth') console.log('note fullwidth', result.note.fullwidth) if (!result.note.tags) result.note.tags = [] result.note.tags = result.note.tags.map((tag) => tag.value) res.header('Access-Control-Allow-Origin', '*') res.send({ title: result.note.title, type: result.note.type, date: (result.note.date && result.note.date.value) || result.note.utcDateCreated, fullwidth: result.note.fullwidth && result.note.fullwidth.value !== 'false', tags: result.note.tags, content: result.content }) } else { res.sendStatus(400) } })()
It does quite a lot to the content to make it suitable for the site.
I also have a third endpoint, image. It just returns an image when requested. It's so that images I upload are available on the site.
Image
const { req, res } = api ;(async () => { if (req.method == 'GET') { const target = req.query.id if (!target) return res.sendStatus(404) let note = await api.getNote(target) let attachment if (!note) { attachment = await api.getAttachment(target) if (attachment) note = await api.getNote(attachment.ownerId) } if (!note) return res.sendStatus(404) if (!attachment && !['image', 'file', 'code'].includes(note.type)) return res.sendStatus(400) const public_fileserve = await note.getAttribute( 'label', 'public_fileserve' ) if (!public_fileserve) return res.sendStatus(403) if (public_fileserve.value !== 'true') return res.sendStatus(403) const mime = attachment ? attachment.mine : ((await note.getAttribute('label', 'serve_mime'))?.value ?? note.mime) res.header('Content-Type', mime) res.header('Access-Control-Allow-Origin', '*') res.header('Cross-Origin-Resource-Policy', 'cross-origin') res.header( 'Content-Security-Policy', 'frame-ancestors https://samwing.dev;' ) res.header('X-Frame-Options', '') const content = await (attachment ?? note).getContent() res.header('Content-Length', content.length) res.send(content) } else { res.sendStatus(400) } })()
Additionally, I have an endpoint for accessing code notes directly.
Code
const { req, res } = api ;async () => { if (req.method == 'GET') { const target = req.query.id if (!target) return res.sendStatus(404) const note = await api.getNote(target) if (!note) return res.sendStatus(404) if (note.type !== 'code') return res.sendStatus(400) const public_fileserve = await note.getAttribute( 'label', 'public_fileserve' ) if (!public_fileserve) return res.sendStatus(403) if (public_fileserve.value !== 'true') return res.sendStatus(403) res.header('Content-Type', note.mime) res.header('Access-Control-Allow-Origin', '*') res.header( 'Content-Security-Policy', 'frame-ancestors https://samwing.dev;' ) res.header('X-Frame-Options', '') const content = await note.getContent() res.header('Content-Length', content.length) res.send(content) } else { res.sendStatus(400) } }
In the samwing.dev note, I have an inherited attribute that allows files or notes to be served by the API.
You may have also noticed that I use getContent and it's not defined anywhere. I have it defined in a code note child in the endpoints:
getContent
const CLASSMAP = { 'language-application-javascript-env-backend': 'language-js', 'language-application-javascript-env-frontend': 'language-js', 'language-text-x-rustsrc': 'language-rust', 'language-text-x-sh': 'language-bash', 'language-text-x-python': 'language-python', 'language-text-x-swift': 'language-swift' } const MIMEMAP = { 'application/javascript;env=backend': 'language-js', 'application/javascript;env=frontend': 'language-js', 'text/x-lua': 'language-lua', } async function getContent(id, recursionDepth) { const { default: cheerio } = await import('/home/node/trilium-data/npm/node_modules/cheerio/index.js') const { decodeHTML } = await import('/home/node/trilium-data/npm/node_modules/entities/dist/index.js') const { firstBy } = (await import('/home/node/trilium-data/npm/node_modules/thenby/thenBy.module.js')).default // Options Sanity Check if (!id) throw new Error('No ID provided') if (!recursionDepth) recursionDepth = 0 // Setup const note = await api.getNote(id) const cNote = note const content = await note.getContent() // --- Files Special Case--- if (note.type === 'file') { let resultingContent = `This is a ${note.mime} file. There is currently not support for displaying ${note.mine} files.` switch (note.mime) { case 'audio/mp4': resultingContent = `<audio controls autoplay src="https://samwing.dev/posts/image/${id}">` break case 'application/pdf': resultingContent = `<a href="https://samwing.dev/posts/image/${id}">View PDF</a><embed src="https://samwing.dev/posts/image/${id}" style='width: 100%; height: 20em; margin-top: 1em;'/>` break } return { note, content: resultingContent } } // --- End Files --- let $ = cheerio.load(content, { xml: { decodeEntities: false } }) // Transforms if (note.type === 'text') { // Convert images to use the public file API instead of trying to use the internal trilium link // The links look like: // api/attachments/<id>/image/image.png // or: // api/images/<id>/image.png $('[src]').map((i, el) => { const e = $(el) try { e.attr( 'src', 'https://samwing.dev/posts/image/' + e.attr('src').split('attachments/')[1].split('/')[0] ) } catch { e.attr( 'src', 'https://samwing.dev/posts/image/' + e.attr('src').split('images/')[1].split('/')[0] ) } e.css('width', '100%') e.css('height', 'auto') const parent = e.parent() if (parent && !parent.css('width')) { parent.css('width', '100%') } }) // Mostly for converting code classes to prism.js ones for (const classMapEntry in CLASSMAP) { replaceClass(classMapEntry, CLASSMAP[classMapEntry]) } // Convert local links to post links $('a').each((i, el) => { let href = $(el).attr('href') if (href.startsWith('#root/')) href = '/posts/' + href.split('/').reverse()[0] $(el).attr('href', href) }) // Put card data on tables $('figure.table > table').each((_0, table) => { let items = [] $(table) .find('thead > tr > th') .each((i, td) => { items[i] = $(td).text() }) $(table) .find('tbody > tr') .each((_1, tr) => { $(tr) .find('td') .each((i, td) => { $(td).attr('data-label', items[i]) // Have you ever seen hackier code? // Puts td content inside a div so that it doesn't break in display: flex $(td).html(`<div>${$(td).html()}</div>`) }) }) }) // Remove inline CSS from tables $('figure.table').each((_, table) => { $(table).attr('style', '') }) await Promise.all( $('code.language-text-plain') .map(async (_, codeEl) => { codeEl = $(codeEl) let text = decodeHTML(codeEl.html()) if (!text.startsWith('{{') || !text.endsWith('}}')) return text = text.substr(1, text.length - 2) const el = codeEl.parent().wrap('<div>').parent() const data = JSON.parse(text) switch (data.template) { case 'subnotes': el.html('<ul></ul>') const children = await note.getChildNotes() if (data.sort === true) children.sort(firstBy('title', -1)) for (const child of children) { if (child.isDeleted) continue if (data.startsWith && !child.title.startsWith(data.startsWith)) continue el.find('ul').append( `<li class="subnotes-${child.noteId}"><a/></li>` ) const a = el.find(`li.subnotes-${child.noteId} > a`) a.attr('href', '/posts/' + child.noteId) a.text(child.title) } break case 'rawhtml': el.replaceWith(data.html) break default: el.html('<p>Unknown Template</p>') break } }) .get() ) } else if (note.type === 'code') { // bit hacky, seems to work ok though $ = cheerio.load('<pre><code></code></pre>', { xml: true }) const mimeClass = MIMEMAP[note.mime] if (mimeClass) $('code').addClass(mimeClass) $('code').text(content) } // Recursion if (recursionDepth < 3) await Promise.all( $('.include-note[data-note-id]') .map(async (i, el) => { try { const { note, content } = await getContent( $(el).attr('data-note-id'), recursionDepth + 1 ) const $2 = cheerio.load(content) const $all = cheerio.load('<div></div>') const all = $all('div') const section = await cNote.getAttribute( 'label', `includeNoteLink_${note.noteId}_section` ) const quote = (await cNote.getAttribute( 'label', `includeNoteLink_${note.noteId}_quote` )) || { value: section ? 'false' : 'true' } const head = (await cNote.getAttribute( 'label', `includeNoteLink_${note.noteId}_head` )) || { value: section ? 'false' : 'true' } if (section) { const sectionName = section.value const header = $2('h1, h2, h3, h4, h5, h6').filter( (_, h) => $2(h).text() === sectionName ) all.append(header.nextUntil(header.prop('tagName'))) $(el).html(all.html()) } else $(el).html(content) if (!head || head.value !== 'false') $(el).html(`<h2>${note.title}</h2>${$(el).html()}`) if (!quote || quote.value !== 'false') $(el).html(`<blockquote>${$(el).html()}</blockquote>`) } catch {} }) .get() ) // Return return { note, content: $.html() } // Utilities function replaceClass(oldClass, newClass) { $(`.${oldClass}`).each((i, el) => { $(el).removeClass(oldClass) $(el).addClass(newClass) }) } } module.exports = getContent
Update: I've added a feed endpoint. It's available at https://trilium.home.wingysam.xyz/custom/wingysam.xyz-feed.
Update 2024-09-22: In an effort to harden my security posture, Trilium is no longer accessible except through a VPN. samwing.dev now proxies it at https://samwing.dev/posts/feed.
Feed
// This script is the API for getting an RSS feed // express.js API const { req, res } = api ;(async () => { // Modules - have to be in an async context to use import() // thenby: my favorite micro-library, helps sorting things const { firstBy } = (await import('/home/node/trilium-data/npm/node_modules/thenby/thenBy.module.js')).default // feed: generates an RSS/Atom feed. const { Feed } = await import('/home/node/trilium-data/npm/node_modules/feed/lib/feed.js') if (req.method == 'GET') { // E0sJWbschXAm is the folder with my posts in it const targetParentNoteId = 'E0sJWbschXAm' const parent = await api.getNote(targetParentNoteId) const children = await parent.getChildNotes() // I don't want drafts in the post list. Should probably extract some things to (... months later, no idea what i was trying to write there) const filtered = ( await Promise.all( children.map(async (note) => { note.con = (await getContent(note.noteId)).content const draft = await note.getAttribute('label', 'draft') if (draft && draft.value === 'true') return null note.date = await note.getAttribute('label', 'date') note.date = (note.date && note.date.value) || note.utcDateCreated note.tags = await note.getAttributes('label', 'tag') if (!note.tags) note.tags = [] note.tags = note.tags.map((tag) => tag.value) note.description = await note.getAttribute('label', 'description') if (!note.description) note.description = { value: 'No description provided' } note.description = note.description.value const article = await note.getAttribute('label', 'article') if (!article) return note if (article.value === 'false') return null return note }) ) ) .filter((noteOrNull) => noteOrNull) .sort(firstBy('date', -1)) res.header('Access-Control-Allow-Origin', '*') const author = { name: 'Wingy', email: '[email protected]', link: 'https://samwing.dev' } const feed = new Feed({ title: 'Sam Wing', description: 'Software Engineer who learns by making', id: 'https://samwing.dev', link: 'https://samwing.dev/posts', language: 'en', image: 'https://samwing.dev/wingy.svg', favicon: 'https://samwing.dev/favicon.png', copyright: `All rights reserved ${new Date().getFullYear()}, Wingy`, feedLinks: {}, author }) filtered.forEach((note) => { feed.addItem({ title: note.title, id: note.noteId, link: `https://samwing.dev/posts/${note.noteId}`, description: note.description, author: [author], date: new Date(note.date), content: note.con || 'none' }) }) res.header('Content-Type', 'application/xml') res.send(feed.rss2()) } else { res.sendStatus(400) } })()
Then I remembered:
So, like any sysadmin, I logged onto the server to make sure everything was okay. I logged on, it worked. The server was up! uptime. The server was rebooted and not just "paused" like QEMU can.
Then I tried my website. Connection refused. I checked nginx. Code 1. journalctl -xe. No nginx logs lately. service nginx start then journalctl -xe. Something's listening on 80. !lsof from that weird time I needed it last. Then adjust to port 80. Lighttpd was listening on port 80! systemctl disable lighttpd. systemctl stop lighttpd.
Then 443. I had forgotten to pm2 save when I decommissioned my old Discord bot which ran its own HTTPS server. That started back up and used 443, but then refused connections. pm2 rm nanobot. Nope, need pm2 del nanobot. Better. Now only NanoBot 2 is running. pm2 save.
service nginx start. Profit!
docker ps -a. VPN is stopped. I should set a --restart always. docker update --restart always gallant_feynman. docker start gallant_feynman. 1194 is already bound. systemctl disable openvpn. systemctl stop openvpn. Okay, now test? docker start gallant_feynman. It works!
pm2 ls. Most things are working, but some things are infinitely restarting. Okay, that http interceptor can go. pm2 del it. Now, Last Seen is infinitely restarting... What is running it? Oh, of course! Systemd and pm2 are fighting. systemctl disable lastseen && service lastseen stop. Now I can start it in pm2. pm2 start lastseen. pm2 ls a few times to make sure it's not restarting and it's not. Good!
Now I wait for the messages to come in telling me what's broken... :(
The lesson of this story is to reboot frequently and be capable of rebooting. I should have been rebooting regularly for updates. It had not been rebooted in more than 400 days!
Thanks for reading.
]]>So obviously, I had to find my DSi and softmod it. If you're looking to do this, please don't look for how on YouTube. Please use https://dsi.cfw.guide.
It took me about 3 hours to go from a DSi that won't boot because the battery is dead to... I press the power button, and it shows me my games! I then proceeded to spend the rest of the day tweaking it, building ROM hacks, etc.
I built 23 New Super Mario Bros ROM hacks from patches. I'm thinking that creating a tool to list games and patches might be feasible.
]]>There's a way to increase the maximum power out of the USB ports, but don't forget to use a good power supply.
It's simple. Type this into your terminal:
sudo echo 'max_usb_current=1' >> /boot/config.txtAnd then reboot your Pi.
Done! Your Raspberry Pi should power whatever you plug into the USB port.
]]>