moshcode 0.38.0 → 0.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -0
- package/bin/moshcode.mjs +3 -0
- package/package.json +1 -1
- package/prd/0010-cloud-settings-sync.md +132 -0
- package/prd/README.md +1 -0
- package/src/aliases.mjs +160 -0
- package/src/cli-schema.mjs +59 -0
- package/src/dns-system.mjs +176 -6
- package/src/dns.mjs +33 -1
- package/src/help.mjs +14 -1
- package/src/settings-sync.mjs +659 -0
- package/src/tui.mjs +161 -14
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
// Cloud sync for the pit's own settings — `/save` and `/load`.
|
|
2
|
+
//
|
|
3
|
+
// The pit accumulates configuration the way a shell rc does: aliases you built
|
|
4
|
+
// up over months, herd rules you tuned for your agents. All of it lives under
|
|
5
|
+
// ~/.moshcode on one machine, which means a new laptop, a fresh container, or a
|
|
6
|
+
// reinstall starts from nothing and the pit feels like someone else's.
|
|
7
|
+
//
|
|
8
|
+
// `/save` pushes that configuration to your app.moshcode.sh account and `/load`
|
|
9
|
+
// brings it back down. Two verbs rather than a background daemon: settings are
|
|
10
|
+
// edited by a person, at a moment they can name, and a sync that runs on its own
|
|
11
|
+
// is a sync that overwrites something you meant to keep at a moment you can't.
|
|
12
|
+
//
|
|
13
|
+
// Three rules the rest of this file exists to enforce:
|
|
14
|
+
//
|
|
15
|
+
// 1. An allowlist, never a directory walk. ~/.moshcode also holds
|
|
16
|
+
// credentials.json — the API token this very feature authenticates with —
|
|
17
|
+
// plus live herd state and a package cache. A walk that gains a file gains
|
|
18
|
+
// it silently; an allowlist has to be edited on purpose, in a diff someone
|
|
19
|
+
// reviews. NEVER_SYNCED is asserted on top of it so the review can't slip.
|
|
20
|
+
// 2. The allowlist is checked again on the way *in*. The response is data from
|
|
21
|
+
// the network, and a path in it is a path this process would write: without
|
|
22
|
+
// the second check a bad snapshot spells `../../.ssh/authorized_keys` and
|
|
23
|
+
// `/load` is a remote write primitive.
|
|
24
|
+
// 3. A revision, and refusal. Two machines both saving means one of them
|
|
25
|
+
// loses; the pit says so and asks, rather than picking for you.
|
|
26
|
+
import crypto from "node:crypto";
|
|
27
|
+
import fs from "node:fs";
|
|
28
|
+
import os from "node:os";
|
|
29
|
+
import path from "node:path";
|
|
30
|
+
import { loadCreds } from "./auth.mjs";
|
|
31
|
+
import { engineStatus } from "./engines.mjs";
|
|
32
|
+
import { toolStatus } from "./tools.mjs";
|
|
33
|
+
import { ash, moshcodeVersion } from "./ui.mjs";
|
|
34
|
+
|
|
35
|
+
/** The snapshot shape this build writes and is willing to read. */
|
|
36
|
+
export const SNAPSHOT_VERSION = 1;
|
|
37
|
+
|
|
38
|
+
/** Owner-only, like everything else moshcode keeps under ~/.moshcode. */
|
|
39
|
+
const FILE_MODE = 0o600;
|
|
40
|
+
const DIR_MODE = 0o700;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* One file at a time, and the whole snapshot. Generous for configuration —
|
|
44
|
+
* aliases.json is a few hundred bytes — and small enough that a stray heredoc
|
|
45
|
+
* pasted into a config file can't push a megabyte into your account, or arrive
|
|
46
|
+
* from it.
|
|
47
|
+
*/
|
|
48
|
+
export const MAX_FILE_BYTES = 64 * 1024;
|
|
49
|
+
export const MAX_TOTAL_BYTES = 256 * 1024;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* What syncs, keyed by its path relative to ~/.moshcode.
|
|
53
|
+
*
|
|
54
|
+
* `json: true` means the file is parsed before it is sent and again before it is
|
|
55
|
+
* written. A settings sync that faithfully copies a broken aliases.json to every
|
|
56
|
+
* machine you own has taken one dead prompt and made it four.
|
|
57
|
+
*/
|
|
58
|
+
export const SYNCED_FILES = [
|
|
59
|
+
{ path: "aliases.json", json: true, label: "pit aliases" },
|
|
60
|
+
{ path: "herd/rules.json", json: true, label: "herd state rules" },
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Paths that must never appear in a snapshot, whichever direction it is moving.
|
|
65
|
+
*
|
|
66
|
+
* Redundant with the allowlist today, and deliberately so: this is the assertion
|
|
67
|
+
* that survives someone adding a convenient-looking entry above. `credentials.json`
|
|
68
|
+
* is the account token — syncing it to the account would hand every machine that
|
|
69
|
+
* ran `/load` a credential it was never issued. `herd/sessions.json` is live
|
|
70
|
+
* state pinned to one tmux server, `pkg/` is a binary cache, and `*.sock` /
|
|
71
|
+
* `*.pid` describe processes on exactly one box.
|
|
72
|
+
*/
|
|
73
|
+
export const NEVER_SYNCED = [
|
|
74
|
+
"credentials.json",
|
|
75
|
+
"sync.json",
|
|
76
|
+
"herd/sessions.json",
|
|
77
|
+
"herd/hook.json",
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
/** True for a path this build is willing to read or write. */
|
|
81
|
+
export function isSyncable(relative) {
|
|
82
|
+
const name = String(relative ?? "");
|
|
83
|
+
if (NEVER_SYNCED.includes(name)) return false;
|
|
84
|
+
if (name.startsWith("pkg/")) return false;
|
|
85
|
+
return SYNCED_FILES.some((f) => f.path === name);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function moshcodeDir(home = os.homedir()) {
|
|
89
|
+
return path.join(home, ".moshcode");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Where the last sync is remembered: the revision we agreed with the server and
|
|
94
|
+
* the digest of the files as they were at that moment.
|
|
95
|
+
*
|
|
96
|
+
* That digest is the whole mechanism behind "you have local changes". Without it
|
|
97
|
+
* `/load` can tell that local and remote differ but not *why* — and "differ" is
|
|
98
|
+
* both "someone else saved from another machine" and "you edited this file five
|
|
99
|
+
* minutes ago", which want opposite answers.
|
|
100
|
+
*/
|
|
101
|
+
export function markerPath(home = os.homedir()) {
|
|
102
|
+
return path.join(moshcodeDir(home), "sync.json");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function loadMarker(home = os.homedir()) {
|
|
106
|
+
try {
|
|
107
|
+
const parsed = JSON.parse(fs.readFileSync(markerPath(home), "utf8"));
|
|
108
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
109
|
+
return parsed;
|
|
110
|
+
} catch { return null; }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function saveMarker(marker, home = os.homedir()) {
|
|
114
|
+
const file = markerPath(home);
|
|
115
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: DIR_MODE });
|
|
116
|
+
fs.writeFileSync(file, `${JSON.stringify(marker, null, 2)}\n`, { mode: FILE_MODE });
|
|
117
|
+
try { fs.chmodSync(file, FILE_MODE); } catch { /* best effort */ }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The digest of a set of files, over their names and contents.
|
|
122
|
+
*
|
|
123
|
+
* Canonical by construction — names sorted, every field framed by a NUL and
|
|
124
|
+
* preceded by its byte length — so the same files digest the same on every
|
|
125
|
+
* machine regardless of the order they were read in, and no content can be
|
|
126
|
+
* arranged to look like a different file list. NUL rather than a space because a
|
|
127
|
+
* space appears in file contents and a NUL does not appear in text config at
|
|
128
|
+
* all.
|
|
129
|
+
*
|
|
130
|
+
* The app computes the same digest over the same bytes
|
|
131
|
+
* (apps/pwa/src/routes/settings-sync.mjs). Both sides pin the value for a fixed
|
|
132
|
+
* input in their tests, because two implementations of one hash that quietly
|
|
133
|
+
* disagree is a comparison that silently stops meaning anything.
|
|
134
|
+
*/
|
|
135
|
+
export function digestFiles(files) {
|
|
136
|
+
const hash = crypto.createHash("sha256");
|
|
137
|
+
for (const name of Object.keys(files).sort()) {
|
|
138
|
+
const content = String(files[name]?.content ?? "");
|
|
139
|
+
hash.update(`${name}\0${Buffer.byteLength(content)}\0${content}\0`);
|
|
140
|
+
}
|
|
141
|
+
return hash.digest("hex");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Engines and tools this machine has, by name. Informational, never applied. */
|
|
145
|
+
function installedHere() {
|
|
146
|
+
const names = (rows) => rows.filter((r) => r.installed).map((r) => r.key).sort();
|
|
147
|
+
try {
|
|
148
|
+
return { engines: names(engineStatus()), tools: names(toolStatus()) };
|
|
149
|
+
} catch { return { engines: [], tools: [] }; }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Read the local settings into a snapshot.
|
|
154
|
+
*
|
|
155
|
+
* Returns `{ snapshot, included, skipped }`. A file that is missing is simply
|
|
156
|
+
* absent — most people have never written herd/rules.json — while one that is
|
|
157
|
+
* present and unusable (too big, not the JSON it claims to be) is reported so
|
|
158
|
+
* the reason is visible rather than looking like it synced.
|
|
159
|
+
*/
|
|
160
|
+
export function collectSnapshot({
|
|
161
|
+
home = os.homedir(),
|
|
162
|
+
hostname = os.hostname(),
|
|
163
|
+
version = moshcodeVersion(),
|
|
164
|
+
installed = installedHere(),
|
|
165
|
+
} = {}) {
|
|
166
|
+
const dir = moshcodeDir(home);
|
|
167
|
+
const files = {};
|
|
168
|
+
const included = [];
|
|
169
|
+
const skipped = [];
|
|
170
|
+
let total = 0;
|
|
171
|
+
|
|
172
|
+
for (const entry of SYNCED_FILES) {
|
|
173
|
+
const file = path.join(dir, entry.path);
|
|
174
|
+
let content;
|
|
175
|
+
try { content = fs.readFileSync(file, "utf8"); }
|
|
176
|
+
catch { continue; } // not here — nothing to say about it
|
|
177
|
+
const bytes = Buffer.byteLength(content);
|
|
178
|
+
if (bytes > MAX_FILE_BYTES) {
|
|
179
|
+
skipped.push({ path: entry.path, reason: `${bytes} bytes — the cap is ${MAX_FILE_BYTES}` });
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (entry.json) {
|
|
183
|
+
try { JSON.parse(content); }
|
|
184
|
+
catch { skipped.push({ path: entry.path, reason: "not valid JSON — fix it locally first" }); continue; }
|
|
185
|
+
}
|
|
186
|
+
if (total + bytes > MAX_TOTAL_BYTES) {
|
|
187
|
+
skipped.push({ path: entry.path, reason: "the snapshot is already at its size cap" });
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
total += bytes;
|
|
191
|
+
files[entry.path] = { content };
|
|
192
|
+
included.push({ path: entry.path, bytes, label: entry.label });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const snapshot = {
|
|
196
|
+
version: SNAPSHOT_VERSION,
|
|
197
|
+
host: String(hostname || "").slice(0, 60) || null,
|
|
198
|
+
moshcode: version || null,
|
|
199
|
+
installed,
|
|
200
|
+
files,
|
|
201
|
+
};
|
|
202
|
+
return { snapshot, included, skipped };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Check a snapshot that came off the network before anything is written.
|
|
207
|
+
*
|
|
208
|
+
* Returns `{ ok, error, files, rejected }`. Rejection is per-file and reported
|
|
209
|
+
* rather than fatal: a newer moshcode that syncs one more file must not make
|
|
210
|
+
* `/load` unusable on this one, so an unknown name is dropped with its reason
|
|
211
|
+
* and the files this build does understand still land.
|
|
212
|
+
*/
|
|
213
|
+
export function validateSnapshot(snapshot) {
|
|
214
|
+
if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) {
|
|
215
|
+
return { ok: false, error: "the saved settings are not a snapshot", files: {}, rejected: [] };
|
|
216
|
+
}
|
|
217
|
+
if (Number(snapshot.version) > SNAPSHOT_VERSION) {
|
|
218
|
+
return {
|
|
219
|
+
ok: false,
|
|
220
|
+
files: {},
|
|
221
|
+
rejected: [],
|
|
222
|
+
error: `these settings were saved by a newer moshcode (snapshot v${snapshot.version}) — run \`moshcode upgrade\` first`,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
const raw = snapshot.files;
|
|
226
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
227
|
+
return { ok: false, error: "the snapshot carries no files", files: {}, rejected: [] };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const files = {};
|
|
231
|
+
const rejected = [];
|
|
232
|
+
let total = 0;
|
|
233
|
+
for (const [name, value] of Object.entries(raw)) {
|
|
234
|
+
// Every reason a name can be refused, in one place. `isSyncable` is the
|
|
235
|
+
// allowlist; the checks around it catch the shapes that never reach it —
|
|
236
|
+
// an absolute path, a traversal, a non-string body.
|
|
237
|
+
if (typeof name !== "string" || !name || name !== path.posix.normalize(name)
|
|
238
|
+
|| path.posix.isAbsolute(name) || name.includes("..") || name.includes("\\")) {
|
|
239
|
+
rejected.push({ path: String(name), reason: "not a settings path" });
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (!isSyncable(name)) { rejected.push({ path: name, reason: "this moshcode does not sync that file" }); continue; }
|
|
243
|
+
const content = value?.content;
|
|
244
|
+
if (typeof content !== "string") { rejected.push({ path: name, reason: "no contents" }); continue; }
|
|
245
|
+
const bytes = Buffer.byteLength(content);
|
|
246
|
+
if (bytes > MAX_FILE_BYTES) { rejected.push({ path: name, reason: `${bytes} bytes — the cap is ${MAX_FILE_BYTES}` }); continue; }
|
|
247
|
+
if (total + bytes > MAX_TOTAL_BYTES) { rejected.push({ path: name, reason: "past the snapshot size cap" }); continue; }
|
|
248
|
+
const entry = SYNCED_FILES.find((f) => f.path === name);
|
|
249
|
+
if (entry?.json) {
|
|
250
|
+
try { JSON.parse(content); }
|
|
251
|
+
catch { rejected.push({ path: name, reason: "not valid JSON — refusing to write it" }); continue; }
|
|
252
|
+
}
|
|
253
|
+
total += bytes;
|
|
254
|
+
files[name] = { content };
|
|
255
|
+
}
|
|
256
|
+
return { ok: true, error: null, files, rejected };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* What `/load` would do, file by file: `new`, `changed` or `same`.
|
|
261
|
+
*
|
|
262
|
+
* Computed before anything is written so --dry-run and the real thing report the
|
|
263
|
+
* same plan, and so "nothing to do" is an answer rather than four no-op writes.
|
|
264
|
+
*/
|
|
265
|
+
export function planApply(files, { home = os.homedir() } = {}) {
|
|
266
|
+
const dir = moshcodeDir(home);
|
|
267
|
+
return Object.keys(files).sort().map((name) => {
|
|
268
|
+
let current = null;
|
|
269
|
+
try { current = fs.readFileSync(path.join(dir, name), "utf8"); } catch { /* absent */ }
|
|
270
|
+
const content = files[name].content;
|
|
271
|
+
return {
|
|
272
|
+
path: name,
|
|
273
|
+
action: current === null ? "new" : current === content ? "same" : "changed",
|
|
274
|
+
bytes: Buffer.byteLength(content),
|
|
275
|
+
};
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Write the snapshot's files. Returns the plan, with `written` marked. */
|
|
280
|
+
export function applyFiles(files, { home = os.homedir() } = {}) {
|
|
281
|
+
const dir = moshcodeDir(home);
|
|
282
|
+
const plan = planApply(files, { home });
|
|
283
|
+
for (const item of plan) {
|
|
284
|
+
if (item.action === "same") continue;
|
|
285
|
+
const file = path.join(dir, item.path);
|
|
286
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: DIR_MODE });
|
|
287
|
+
// Written beside the target and renamed over it: a settings file truncated
|
|
288
|
+
// by a full disk halfway through a write is a prompt that no longer starts.
|
|
289
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
290
|
+
fs.writeFileSync(temp, files[item.path].content, { mode: FILE_MODE });
|
|
291
|
+
fs.renameSync(temp, file);
|
|
292
|
+
try { fs.chmodSync(file, FILE_MODE); } catch { /* best effort */ }
|
|
293
|
+
item.written = true;
|
|
294
|
+
}
|
|
295
|
+
return plan;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Which local files have drifted from the last sync.
|
|
300
|
+
*
|
|
301
|
+
* Names, not a boolean, because that list is the message: "aliases.json changed
|
|
302
|
+
* since you last saved" is actionable and "local and remote differ" is not.
|
|
303
|
+
*/
|
|
304
|
+
export function localDrift({ home = os.homedir() } = {}) {
|
|
305
|
+
const marker = loadMarker(home);
|
|
306
|
+
const { snapshot } = collectSnapshot({ home, installed: { engines: [], tools: [] } });
|
|
307
|
+
const digest = digestFiles(snapshot.files);
|
|
308
|
+
if (!marker?.digest) return { known: false, drifted: true, digest, files: Object.keys(snapshot.files).sort() };
|
|
309
|
+
if (marker.digest === digest) return { known: true, drifted: false, digest, files: [] };
|
|
310
|
+
const before = marker.files && typeof marker.files === "object" ? marker.files : null;
|
|
311
|
+
const files = before
|
|
312
|
+
? [...new Set([...Object.keys(before), ...Object.keys(snapshot.files)])]
|
|
313
|
+
.filter((name) => (before[name] ?? null) !== fileDigest(snapshot.files[name]))
|
|
314
|
+
.sort()
|
|
315
|
+
: Object.keys(snapshot.files).sort();
|
|
316
|
+
return { known: true, drifted: true, digest, files };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Per-file digest, so the marker can name which file moved rather than just that one did. */
|
|
320
|
+
function fileDigest(entry) {
|
|
321
|
+
if (!entry || typeof entry.content !== "string") return null;
|
|
322
|
+
return crypto.createHash("sha256").update(entry.content).digest("hex");
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** The marker to write after a successful push or pull. */
|
|
326
|
+
export function markerFor({ revision, digest, files, host = os.hostname(), api }) {
|
|
327
|
+
return {
|
|
328
|
+
revision: Number(revision),
|
|
329
|
+
digest,
|
|
330
|
+
at: Date.now(),
|
|
331
|
+
host: String(host || "").slice(0, 60) || null,
|
|
332
|
+
api: api || null,
|
|
333
|
+
files: Object.fromEntries(Object.keys(files).sort().map((name) => [name, fileDigest(files[name])])),
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/* ------------------------------------------------------------------ transport */
|
|
338
|
+
|
|
339
|
+
const DEFAULT_API = "https://app.moshcode.sh";
|
|
340
|
+
|
|
341
|
+
function endpoint(creds) {
|
|
342
|
+
return (process.env.MOSHCODE_API || creds?.api || DEFAULT_API).replace(/\/+$/, "");
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* A request against the settings API, with every failure turned into a value.
|
|
347
|
+
*
|
|
348
|
+
* `{ ok, status, body, error }`. The callers here print a line and set an exit
|
|
349
|
+
* code; a thrown network error inside the pit's dispatch loop would take the
|
|
350
|
+
* prompt down instead, which is a lost session over a dropped wifi connection.
|
|
351
|
+
*/
|
|
352
|
+
async function request(method, route, { creds, body = null, fetchImpl = fetch, timeoutMs = 20_000 } = {}) {
|
|
353
|
+
const controller = new AbortController();
|
|
354
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
355
|
+
try {
|
|
356
|
+
const res = await fetchImpl(`${endpoint(creds)}${route}`, {
|
|
357
|
+
method,
|
|
358
|
+
headers: {
|
|
359
|
+
"content-type": "application/json",
|
|
360
|
+
authorization: `Bearer ${creds?.token}`,
|
|
361
|
+
},
|
|
362
|
+
body: body === null ? undefined : JSON.stringify(body),
|
|
363
|
+
signal: controller.signal,
|
|
364
|
+
});
|
|
365
|
+
const text = await res.text().catch(() => "");
|
|
366
|
+
let parsed = null;
|
|
367
|
+
try { parsed = text ? JSON.parse(text) : null; } catch { /* not JSON — reported as a status */ }
|
|
368
|
+
return { ok: res.ok, status: res.status, body: parsed, error: null };
|
|
369
|
+
} catch (e) {
|
|
370
|
+
const aborted = e?.name === "AbortError";
|
|
371
|
+
return { ok: false, status: 0, body: null, error: aborted ? "the app did not answer in time" : "could not reach the app" };
|
|
372
|
+
} finally {
|
|
373
|
+
clearTimeout(timer);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export const pushSnapshot = (snapshot, { ifRevision = null, ...opts }) =>
|
|
378
|
+
request("PUT", "/api/settings", { ...opts, body: { snapshot, ifRevision } });
|
|
379
|
+
|
|
380
|
+
export const pullSnapshot = (opts) => request("GET", "/api/settings", opts);
|
|
381
|
+
|
|
382
|
+
export const listRevisions = (opts) => request("GET", "/api/settings/revisions", opts);
|
|
383
|
+
|
|
384
|
+
/* ------------------------------------------------------------------- commands */
|
|
385
|
+
|
|
386
|
+
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
387
|
+
|
|
388
|
+
function whenever(at) {
|
|
389
|
+
const seconds = Math.max(0, Math.floor((Date.now() - Number(at)) / 1000));
|
|
390
|
+
if (!Number.isFinite(seconds)) return "at an unknown time";
|
|
391
|
+
if (seconds < 60) return `${seconds}s ago`;
|
|
392
|
+
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
|
|
393
|
+
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
|
|
394
|
+
return `${Math.floor(seconds / 86400)}d ago`;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** The flags both verbs share, plus whatever the caller adds. */
|
|
398
|
+
function parseFlags(argv, allowed) {
|
|
399
|
+
const flags = new Set();
|
|
400
|
+
const unknown = [];
|
|
401
|
+
for (const arg of argv) {
|
|
402
|
+
const name = String(arg);
|
|
403
|
+
if (allowed.includes(name)) flags.add(name);
|
|
404
|
+
else unknown.push(name);
|
|
405
|
+
}
|
|
406
|
+
return { flags, unknown };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const notLoggedIn = (write) => {
|
|
410
|
+
write("not logged in — run `/login` (or `moshcode login`) first");
|
|
411
|
+
write(" settings sync stores your configuration on your app.moshcode.sh account");
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* `/save` — push the local settings to the account.
|
|
416
|
+
*
|
|
417
|
+
* Returns an exit code, the convention every other command module here uses, so
|
|
418
|
+
* `moshcode save` in a script can be tested for having worked.
|
|
419
|
+
*/
|
|
420
|
+
export async function saveCommand(argv = [], {
|
|
421
|
+
home = os.homedir(),
|
|
422
|
+
creds = loadCreds(),
|
|
423
|
+
fetchImpl = fetch,
|
|
424
|
+
write = (line) => console.log(line),
|
|
425
|
+
hostname = os.hostname(),
|
|
426
|
+
version = moshcodeVersion(),
|
|
427
|
+
installed = installedHere(),
|
|
428
|
+
} = {}) {
|
|
429
|
+
const { flags, unknown } = parseFlags(argv, ["--dry-run", "--force", "--json"]);
|
|
430
|
+
if (unknown.length) {
|
|
431
|
+
write(`unknown option ${unknown[0]} — usage: save [--dry-run] [--force] [--json]`);
|
|
432
|
+
return 1;
|
|
433
|
+
}
|
|
434
|
+
const json = flags.has("--json");
|
|
435
|
+
const emit = (value) => { write(JSON.stringify(value, null, 2)); };
|
|
436
|
+
|
|
437
|
+
const { snapshot, included, skipped } = collectSnapshot({ home, hostname, version, installed });
|
|
438
|
+
const digest = digestFiles(snapshot.files);
|
|
439
|
+
|
|
440
|
+
if (!included.length) {
|
|
441
|
+
if (json) emit({ status: "nothing_to_save", files: [], skipped });
|
|
442
|
+
else {
|
|
443
|
+
write("nothing to save yet — the pit has no settings on this machine");
|
|
444
|
+
write(' make one first: `/alias set gs "git status"`');
|
|
445
|
+
for (const s of skipped) write(` skipped ${s.path} — ${s.reason}`);
|
|
446
|
+
}
|
|
447
|
+
return 0;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (!creds?.token) {
|
|
451
|
+
if (json) emit({ status: "not_logged_in", files: included });
|
|
452
|
+
else notLoggedIn(write);
|
|
453
|
+
return 1;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const marker = loadMarker(home);
|
|
457
|
+
if (flags.has("--dry-run")) {
|
|
458
|
+
if (json) emit({ status: "dry_run", digest, revision: marker?.revision ?? null, files: included, skipped });
|
|
459
|
+
else {
|
|
460
|
+
write(`would save ${plural(included.length, "file")} to ${endpoint(creds)}:`);
|
|
461
|
+
for (const f of included) write(` ${f.path} ${ash(`${f.bytes}b · ${f.label}`)}`);
|
|
462
|
+
for (const s of skipped) write(` skipped ${s.path} — ${s.reason}`);
|
|
463
|
+
}
|
|
464
|
+
return 0;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// "Nothing changed" is the account's answer, not this machine's guess. The app
|
|
468
|
+
// recognises a byte-identical snapshot and hands back the revision it already
|
|
469
|
+
// holds without inserting one, so an unchanged `/save` still costs no history —
|
|
470
|
+
// and a machine whose local marker has gone stale (someone deleted the saved
|
|
471
|
+
// settings from the web) finds out instead of insisting it is up to date.
|
|
472
|
+
const res = await pushSnapshot(snapshot, {
|
|
473
|
+
creds,
|
|
474
|
+
fetchImpl,
|
|
475
|
+
// The revision we last agreed on. The server refuses the write if it has
|
|
476
|
+
// moved on, which is the whole conflict story: another machine saved, and
|
|
477
|
+
// this push would erase it silently.
|
|
478
|
+
ifRevision: flags.has("--force") ? null : (Number.isFinite(Number(marker?.revision)) ? Number(marker.revision) : null),
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
if (res.status === 409) {
|
|
482
|
+
const theirs = res.body?.revision;
|
|
483
|
+
if (json) emit({ status: "conflict", revision: theirs ?? null, mine: marker?.revision ?? null });
|
|
484
|
+
else if (Number(theirs) === 0) {
|
|
485
|
+
// Not a race: the account's saved settings were deleted (the web page's
|
|
486
|
+
// "forget"), so there is nothing to lose and nothing to load.
|
|
487
|
+
write(`the account has no saved settings — this machine last saw revision ${marker?.revision ?? "none"}`);
|
|
488
|
+
write(" `/save --force` to save this machine's settings as the new revision 1");
|
|
489
|
+
} else {
|
|
490
|
+
write(`another machine saved first — the account is at revision ${theirs ?? "?"}, this one last saw ${marker?.revision ?? "none"}`);
|
|
491
|
+
write(" `/load` to take theirs, or `/save --force` to overwrite it with this machine's settings");
|
|
492
|
+
}
|
|
493
|
+
return 1;
|
|
494
|
+
}
|
|
495
|
+
if (res.status === 401) {
|
|
496
|
+
if (json) emit({ status: "expired" });
|
|
497
|
+
else write("the app rejected this machine's credentials — run `/login` again");
|
|
498
|
+
return 1;
|
|
499
|
+
}
|
|
500
|
+
if (!res.ok || !res.body?.revision) {
|
|
501
|
+
if (json) emit({ status: "failed", error: res.error, http: res.status || null });
|
|
502
|
+
else write(`could not save: ${res.error || `the app returned ${res.status}`}`);
|
|
503
|
+
return 1;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
saveMarker(markerFor({
|
|
507
|
+
revision: res.body.revision,
|
|
508
|
+
digest,
|
|
509
|
+
files: snapshot.files,
|
|
510
|
+
host: hostname,
|
|
511
|
+
api: endpoint(creds),
|
|
512
|
+
}), home);
|
|
513
|
+
|
|
514
|
+
if (res.body.unchanged) {
|
|
515
|
+
if (json) emit({ status: "unchanged", revision: res.body.revision, digest, files: included, skipped });
|
|
516
|
+
else write(`already saved — revision ${res.body.revision} holds these exact files${res.body.savedAt ? `, from ${whenever(res.body.savedAt)}` : ""}`);
|
|
517
|
+
return 0;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (json) {
|
|
521
|
+
emit({ status: "saved", revision: res.body.revision, digest, files: included, skipped });
|
|
522
|
+
return 0;
|
|
523
|
+
}
|
|
524
|
+
write(`saved ${plural(included.length, "file")} to ${creds.email || "your account"} ${ash(`(revision ${res.body.revision})`)}`);
|
|
525
|
+
for (const f of included) write(` ${f.path} ${ash(f.label)}`);
|
|
526
|
+
for (const s of skipped) write(` skipped ${s.path} — ${s.reason}`);
|
|
527
|
+
write(ash(" on another machine: `/login` then `/load`"));
|
|
528
|
+
return 0;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** `/load` — bring the account's settings down onto this machine. */
|
|
532
|
+
export async function loadCommand(argv = [], {
|
|
533
|
+
home = os.homedir(),
|
|
534
|
+
creds = loadCreds(),
|
|
535
|
+
fetchImpl = fetch,
|
|
536
|
+
write = (line) => console.log(line),
|
|
537
|
+
hostname = os.hostname(),
|
|
538
|
+
installed = installedHere(),
|
|
539
|
+
} = {}) {
|
|
540
|
+
const { flags, unknown } = parseFlags(argv, ["--dry-run", "--force", "--json"]);
|
|
541
|
+
if (unknown.length) {
|
|
542
|
+
write(`unknown option ${unknown[0]} — usage: load [--dry-run] [--force] [--json]`);
|
|
543
|
+
return 1;
|
|
544
|
+
}
|
|
545
|
+
const json = flags.has("--json");
|
|
546
|
+
const emit = (value) => { write(JSON.stringify(value, null, 2)); };
|
|
547
|
+
|
|
548
|
+
if (!creds?.token) {
|
|
549
|
+
if (json) emit({ status: "not_logged_in" });
|
|
550
|
+
else notLoggedIn(write);
|
|
551
|
+
return 1;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const res = await pullSnapshot({ creds, fetchImpl });
|
|
555
|
+
if (res.status === 404) {
|
|
556
|
+
if (json) emit({ status: "empty" });
|
|
557
|
+
else {
|
|
558
|
+
write("nothing saved to this account yet");
|
|
559
|
+
write(" run `/save` on the machine whose settings you want, then `/load` here");
|
|
560
|
+
}
|
|
561
|
+
return 1;
|
|
562
|
+
}
|
|
563
|
+
if (res.status === 401) {
|
|
564
|
+
if (json) emit({ status: "expired" });
|
|
565
|
+
else write("the app rejected this machine's credentials — run `/login` again");
|
|
566
|
+
return 1;
|
|
567
|
+
}
|
|
568
|
+
if (!res.ok) {
|
|
569
|
+
if (json) emit({ status: "failed", error: res.error, http: res.status || null });
|
|
570
|
+
else write(`could not load: ${res.error || `the app returned ${res.status}`}`);
|
|
571
|
+
return 1;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const { ok: valid, error, files, rejected } = validateSnapshot(res.body?.snapshot);
|
|
575
|
+
if (!valid) {
|
|
576
|
+
if (json) emit({ status: "invalid", error });
|
|
577
|
+
else write(`could not load: ${error}`);
|
|
578
|
+
return 1;
|
|
579
|
+
}
|
|
580
|
+
const plan = planApply(files, { home });
|
|
581
|
+
const changes = plan.filter((p) => p.action !== "same");
|
|
582
|
+
const revision = res.body?.revision ?? null;
|
|
583
|
+
const from = res.body?.snapshot?.host || res.body?.host || null;
|
|
584
|
+
|
|
585
|
+
// Local edits that were never saved. Overwriting them is exactly what `/load`
|
|
586
|
+
// is for on a fresh machine and exactly what it must not do on a working one,
|
|
587
|
+
// and only the person at the prompt knows which this is.
|
|
588
|
+
const drift = localDrift({ home });
|
|
589
|
+
const clobbers = drift.drifted
|
|
590
|
+
? changes.filter((c) => c.action === "changed" && (!drift.known || drift.files.includes(c.path)))
|
|
591
|
+
: [];
|
|
592
|
+
if (clobbers.length && !flags.has("--force") && !flags.has("--dry-run")) {
|
|
593
|
+
if (json) emit({ status: "local_changes", revision, files: clobbers.map((c) => c.path) });
|
|
594
|
+
else {
|
|
595
|
+
write(`${plural(clobbers.length, "local file")} changed since this machine last synced:`);
|
|
596
|
+
for (const c of clobbers) write(` ${c.path}`);
|
|
597
|
+
write(" `/save` to keep them, `/load --force` to replace them, `/load --dry-run` to see the difference");
|
|
598
|
+
}
|
|
599
|
+
return 1;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (flags.has("--dry-run")) {
|
|
603
|
+
if (json) emit({ status: "dry_run", revision, from, plan, rejected });
|
|
604
|
+
else {
|
|
605
|
+
write(changes.length
|
|
606
|
+
? `revision ${revision} from ${from || "another machine"} would change ${plural(changes.length, "file")}:`
|
|
607
|
+
: `revision ${revision} from ${from || "another machine"} matches this machine — nothing to do`);
|
|
608
|
+
for (const item of plan) write(` ${item.action.padEnd(8)} ${item.path}`);
|
|
609
|
+
for (const r of rejected) write(` ignored ${r.path} — ${r.reason}`);
|
|
610
|
+
if (clobbers.length) {
|
|
611
|
+
write(` ${plural(clobbers.length, "file")} would replace local changes — a plain \`/load\` will ask for --force`);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
return 0;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if (!changes.length) {
|
|
618
|
+
// Still write the marker: the files match, so this machine *is* at that
|
|
619
|
+
// revision, and recording it is what lets the next `/save` push without
|
|
620
|
+
// being told it might be clobbering someone.
|
|
621
|
+
saveMarker(markerFor({ revision, digest: digestFiles(files), files, host: hostname, api: endpoint(creds) }), home);
|
|
622
|
+
if (json) emit({ status: "unchanged", revision, files: [] });
|
|
623
|
+
else write(`already at revision ${revision} — nothing to change`);
|
|
624
|
+
return 0;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
let applied;
|
|
628
|
+
try { applied = applyFiles(files, { home }); }
|
|
629
|
+
catch (e) {
|
|
630
|
+
if (json) emit({ status: "failed", error: String(e.message || e) });
|
|
631
|
+
else write(`could not write the settings: ${String(e.message || e)}`);
|
|
632
|
+
return 1;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
saveMarker(markerFor({ revision, digest: digestFiles(files), files, host: hostname, api: endpoint(creds) }), home);
|
|
636
|
+
|
|
637
|
+
const written = applied.filter((p) => p.written);
|
|
638
|
+
if (json) {
|
|
639
|
+
emit({ status: "loaded", revision, from, files: written.map((w) => w.path), rejected });
|
|
640
|
+
return 0;
|
|
641
|
+
}
|
|
642
|
+
write(`loaded revision ${revision}${from ? ` from ${from}` : ""} — ${plural(written.length, "file")} written`);
|
|
643
|
+
for (const item of written) write(` ${item.action === "new" ? "added " : "replaced"} ${item.path}`);
|
|
644
|
+
for (const r of rejected) write(` ignored ${r.path} — ${r.reason}`);
|
|
645
|
+
|
|
646
|
+
// Names only, and only the missing ones. The snapshot records what the source
|
|
647
|
+
// machine had installed because that is most of what makes a pit feel like
|
|
648
|
+
// yours — but installing an engine is a download and a shell script, so this
|
|
649
|
+
// is a sentence, not an action.
|
|
650
|
+
const theirs = res.body?.snapshot?.installed || {};
|
|
651
|
+
const missing = [
|
|
652
|
+
...(theirs.engines || []).filter((n) => !(installed.engines || []).includes(n)),
|
|
653
|
+
...(theirs.tools || []).filter((n) => !(installed.tools || []).includes(n)),
|
|
654
|
+
];
|
|
655
|
+
if (missing.length) {
|
|
656
|
+
write(ash(` that machine also had ${missing.join(", ")} — \`/install <name>\` to match it`));
|
|
657
|
+
}
|
|
658
|
+
return 0;
|
|
659
|
+
}
|