fedipod-server 0.11.0 → 0.13.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 +22 -6
- package/dist/claims.d.ts +8 -0
- package/dist/claims.js +10 -0
- package/dist/handler.d.ts +13 -0
- package/dist/handler.js +56 -11
- package/dist/handler.jsonld +8 -0
- package/dist/store-pod.js +18 -4
- package/lib/{c2s.mjs → client/c2s.mjs} +10 -3
- package/lib/{localapi.mjs → client/localapi.mjs} +2 -2
- package/lib/client/masto/accounts.mjs +264 -0
- package/lib/client/masto/body.mjs +69 -0
- package/lib/client/masto/index.mjs +183 -0
- package/lib/client/masto/instance.mjs +104 -0
- package/lib/client/masto/media.mjs +133 -0
- package/lib/client/masto/oauth.mjs +599 -0
- package/lib/client/masto/render.mjs +459 -0
- package/lib/client/masto/statuses.mjs +331 -0
- package/lib/client/masto/timelines.mjs +316 -0
- package/lib/{streaming.mjs → client/streaming.mjs} +1 -1
- package/lib/{acctfeed.mjs → connections/acctfeed.mjs} +1 -1
- package/lib/{atproto.mjs → connections/atproto.mjs} +15 -16
- package/lib/{bskygroup.mjs → connections/bskygroup.mjs} +1 -1
- package/lib/{fediacct.mjs → connections/fediacct.mjs} +31 -35
- package/lib/{import.mjs → connections/import.mjs} +1 -1
- package/lib/{tagfeed.mjs → connections/tagfeed.mjs} +3 -3
- package/lib/connections/vault.mjs +114 -0
- package/lib/core/as2.mjs +170 -0
- package/lib/core/contexts/activitystreams.json +379 -0
- package/lib/core/contexts/did-v1.json +57 -0
- package/lib/core/contexts/fep-5711.json +36 -0
- package/lib/core/contexts/gotosocial.json +86 -0
- package/lib/core/contexts/identity-v1.json +152 -0
- package/lib/core/contexts/index.mjs +45 -0
- package/lib/core/contexts/join-lemmy.json +33 -0
- package/lib/core/contexts/joinmastodon.json +28 -0
- package/lib/core/contexts/map.json +16 -0
- package/lib/core/contexts/miscellany.json +19 -0
- package/lib/core/contexts/schemaorg.json +8845 -0
- package/lib/core/contexts/security-data-integrity-v1.json +78 -0
- package/lib/core/contexts/security-data-integrity-v2.json +81 -0
- package/lib/core/contexts/security-multikey-v1.json +35 -0
- package/lib/core/contexts/security-v1.json +74 -0
- package/lib/core/contexts/webfinger.json +10 -0
- package/lib/{deliver.mjs → core/deliver.mjs} +2 -2
- package/lib/core/graphview.mjs +269 -0
- package/lib/core/intake/activities.mjs +437 -0
- package/lib/core/intake/activity.mjs +240 -0
- package/lib/core/intake/channel.mjs +144 -0
- package/lib/core/intake/group.mjs +222 -0
- package/lib/core/intake/index.mjs +629 -0
- package/lib/core/intake/notes.mjs +288 -0
- package/lib/core/intake/verify.mjs +142 -0
- package/lib/{keys.mjs → core/keys.mjs} +1 -1
- package/lib/core/publisher/collections.mjs +229 -0
- package/lib/core/publisher/index.mjs +421 -0
- package/lib/core/publisher/notes.mjs +188 -0
- package/lib/core/publisher/questions.mjs +233 -0
- package/lib/core/publisher/restore.mjs +199 -0
- package/lib/core/shapes/activitystreams.ttl +129 -0
- package/lib/core/shapes/index.mjs +107 -0
- package/lib/core/shapes/shapes-text.mjs +13 -0
- package/lib/{social.mjs → core/social.mjs} +2 -2
- package/lib/{store.mjs → core/store.mjs} +4 -0
- package/lib/{wire.mjs → core/wire.mjs} +2 -2
- package/lib/device/admin/index.mjs +13 -0
- package/lib/device/admin/origins.mjs +35 -0
- package/lib/device/admin/routes/connections.mjs +144 -0
- package/lib/device/admin/routes/gateway.mjs +199 -0
- package/lib/device/admin/routes/lifecycle.mjs +191 -0
- package/lib/device/admin/routes/owner.mjs +322 -0
- package/lib/device/admin/routes/setup.mjs +393 -0
- package/lib/device/admin/routes/social.mjs +188 -0
- package/lib/device/admin/server.mjs +95 -0
- package/lib/device/admin/static.mjs +244 -0
- package/lib/device/admin/surface.mjs +274 -0
- package/lib/device/cli/commands/account.mjs +586 -0
- package/lib/device/cli/commands/run.mjs +278 -0
- package/lib/device/cli/commands/service.mjs +221 -0
- package/lib/device/cli/commands/setup.mjs +410 -0
- package/lib/device/cli/commands/state.mjs +559 -0
- package/lib/device/cli/context.mjs +288 -0
- package/lib/{migrate.mjs → device/migrate.mjs} +1 -1
- package/lib/{remote.mjs → device/remote.mjs} +3 -3
- package/lib/{setup.mjs → device/setup.mjs} +3 -3
- package/lib/{update.mjs → device/update.mjs} +1 -1
- package/lib/{directory.mjs → gateway/directory.mjs} +1 -1
- package/lib/{front-core.mjs → gateway/front-core.mjs} +3 -3
- package/lib/{gateway-core.mjs → gateway/gateway-core.mjs} +1 -1
- package/lib/{httpsig.mjs → gateway/httpsig.mjs} +1 -1
- package/lib/server/embed.mjs +405 -0
- package/lib/{links.mjs → shared/links.mjs} +1 -1
- package/lib/{ua.mjs → shared/ua.mjs} +1 -1
- package/package.json +1 -1
- package/run-agent.mjs +33 -25
- package/web/admin/actors.js +145 -0
- package/web/admin/common.js +23 -0
- package/web/admin/connections.js +112 -0
- package/web/admin/gateway.js +111 -0
- package/web/admin/group.js +258 -0
- package/web/admin/index.html +7 -1
- package/web/admin/record.js +378 -0
- package/web/admin/setup/index.html +1 -0
- package/web/admin/setup/setup.js +2 -13
- package/web/admin/upkeep.js +170 -0
- package/web/app/README.md +6 -6
- package/web/app/admin-facade.mjs +3 -3
- package/web/app/agent.mjs +14 -16
- package/web/app/atproto-browser.mjs +1 -1
- package/web/app/boot.mjs +2 -3
- package/web/app/deliver-relay.mjs +1 -1
- package/web/app/dist/boot.js +22 -3
- package/web/app/dist/boot.js.map +2 -2
- package/web/app/dist/sw.js +21913 -5446
- package/web/app/dist/sw.js.map +4 -4
- package/web/app/fediacct-browser.mjs +1 -1
- package/web/app/keys-browser.mjs +27 -4
- package/web/app/shims/shapes-text.mjs +8 -0
- package/web/app/signup.mjs +2 -3
- package/web/app/site/admin/actors.js +145 -0
- package/web/app/site/admin/common.js +23 -0
- package/web/app/site/admin/connections.js +112 -0
- package/web/app/site/admin/gateway.js +111 -0
- package/web/app/site/admin/group.js +258 -0
- package/web/app/site/admin/index.html +7 -1
- package/web/app/site/admin/record.js +378 -0
- package/web/app/site/admin/setup/index.html +1 -0
- package/web/app/site/admin/setup/setup.js +2 -13
- package/web/app/site/admin/upkeep.js +170 -0
- package/web/app/site/boot.js +22 -3
- package/web/app/site/sw.js +21913 -5446
- package/web/app/sw-src.mjs +17 -2
- package/lib/admin.mjs +0 -1913
- package/lib/embed.mjs +0 -220
- package/lib/intake.mjs +0 -1981
- package/lib/mastoapi.mjs +0 -2284
- package/lib/publisher.mjs +0 -1192
- package/web/admin/admin.js +0 -1181
- package/web/app/site/admin/admin.js +0 -1181
- /package/lib/{oidc-auth.mjs → client/oidc-auth.mjs} +0 -0
- /package/lib/{webpush.mjs → client/webpush.mjs} +0 -0
- /package/lib/{bskyfeed.mjs → connections/bskyfeed.mjs} +0 -0
- /package/lib/{lease.mjs → core/lease.mjs} +0 -0
- /package/lib/{polls.mjs → core/polls.mjs} +0 -0
- /package/lib/{proof.mjs → core/proof.mjs} +0 -0
- /package/lib/{storage.mjs → core/storage.mjs} +0 -0
- /package/lib/{account.mjs → device/account.mjs} +0 -0
- /package/lib/{certs.mjs → device/certs.mjs} +0 -0
- /package/lib/{export-collections.mjs → device/export-collections.mjs} +0 -0
- /package/lib/{home.mjs → device/home.mjs} +0 -0
- /package/lib/{ports.mjs → device/ports.mjs} +0 -0
- /package/lib/{guard.mjs → shared/guard.mjs} +0 -0
- /package/lib/{safefetch.mjs → shared/safefetch.mjs} +0 -0
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
// state.mjs — where things live and moving them: state (this identity's
|
|
2
|
+
// private half, or every identity's), upgrade, profiles, home, export.
|
|
3
|
+
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
9
|
+
import { identityHomes, isLegacyRoot, CURRENT_ROOT, tildify, defaultProfile, profileHome, rootHoldsIdentity, recordLastUsed, writeJsonAtomic } from '../../home.mjs';
|
|
10
|
+
import { insecureUrlReason } from '../../../shared/safefetch.mjs';
|
|
11
|
+
import { localFetch } from '../../../client/localapi.mjs';
|
|
12
|
+
import { args, flag, has, PROFILE, AP_ROOT, HOME, PORT, requireHandle, requireIdentity, somethingOn, agentOn, isInside } from '../context.mjs';
|
|
13
|
+
|
|
14
|
+
export async function state() {
|
|
15
|
+
if (has('all') || has('drop-remote')) return stateAll();
|
|
16
|
+
requireIdentity();
|
|
17
|
+
// Where the private half lives, and how to move it. Copy, verify, THEN
|
|
18
|
+
// repoint — a pointer moved on its own leaves the agent reading one
|
|
19
|
+
// container and writing another, which is the divergence this avoids.
|
|
20
|
+
// The old copy is left behind on purpose; delete it when you are satisfied.
|
|
21
|
+
const credPath = path.join(HOME, 'credential.json');
|
|
22
|
+
let cred;
|
|
23
|
+
try { cred = JSON.parse(fs.readFileSync(credPath, 'utf8')); }
|
|
24
|
+
catch { console.error(`no identity in ${HOME} — run setup first`); process.exit(2); }
|
|
25
|
+
|
|
26
|
+
const { apUrls } = await import(new URL('../../../../lib/core/wire.mjs', import.meta.url));
|
|
27
|
+
const { Agent } = await import(new URL('../../../../run-agent.mjs', import.meta.url));
|
|
28
|
+
const where = (c) => (c.privateRoot ? tildify(c.privateRoot) : `${apUrls(c.remotePod, c.root).home}(on the pod)`);
|
|
29
|
+
|
|
30
|
+
const to = flag('to');
|
|
31
|
+
if (!to) {
|
|
32
|
+
console.log(`private data: ${where(cred)}`);
|
|
33
|
+
console.log(`public face: ${apUrls(cred.remotePod, cred.root).home}`);
|
|
34
|
+
console.log('\nTo move it: fedipod state --to ~/somewhere/private/');
|
|
35
|
+
console.log(' fedipod state --to <container-url>');
|
|
36
|
+
console.log(' fedipod state --to pod');
|
|
37
|
+
process.exit(0);
|
|
38
|
+
}
|
|
39
|
+
if (await localFetch(HOME, PORT, `/status`).then(() => true).catch(() => false)) {
|
|
40
|
+
console.error(`an agent is running on port ${PORT} — stop it first: fedipod stop`);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
// A path or a URL. `state` prints the path form, so refusing it here would
|
|
44
|
+
// mean what the command shows you is not what it takes back — two chars before
|
|
45
|
+
// the colon, so a Windows drive letter is a path rather than a scheme.
|
|
46
|
+
let target = null;
|
|
47
|
+
if (to !== 'pod') {
|
|
48
|
+
const asPath = !/^[a-z][a-z0-9+.-]+:/i.test(to);
|
|
49
|
+
const raw = asPath
|
|
50
|
+
? pathToFileURL(path.resolve(to.replace(/^~(?=[/\\]|$)/, os.homedir()))).href
|
|
51
|
+
: to;
|
|
52
|
+
target = raw.endsWith('/') ? raw : raw + '/';
|
|
53
|
+
try { new URL(target); } catch { console.error(`"${to}" is not a container URL or a path`); process.exit(2); }
|
|
54
|
+
}
|
|
55
|
+
if ((cred.privateRoot || null) === target) { console.log(`already there: ${where(cred)}`); process.exit(0); }
|
|
56
|
+
// Before anything is built or sent, not after. This check used to sit at the
|
|
57
|
+
// very bottom, past the copy — so `--to http://nas.local/private/`, a typo
|
|
58
|
+
// for https or a plaintext box on the LAN, wrote every state document
|
|
59
|
+
// (masto-tokens.json included) and every RDF note to that host in the clear,
|
|
60
|
+
// and only then said the address was refused. The messages around it, which
|
|
61
|
+
// say nothing was repointed and the old copy was left where it was, were
|
|
62
|
+
// true and read as "nothing happened".
|
|
63
|
+
if (/^https?:/i.test(target || '')) {
|
|
64
|
+
const bad = insecureUrlReason(target, 'private-data address');
|
|
65
|
+
if (bad) { console.error(bad); process.exit(2); }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const agent = new Agent({ home: HOME, log: (...a) => console.log('[state]', ...a) });
|
|
69
|
+
agent.urls = apUrls(cred.remotePod, cred.root);
|
|
70
|
+
// Only one of the two sides can be the pod, and moving between two local
|
|
71
|
+
// pods needs no credential at all — so do not spend a token grant on it.
|
|
72
|
+
if (!cred.privateRoot || !target) {
|
|
73
|
+
const { RemotePod } = await import(new URL('../../../../lib/device/remote.mjs', import.meta.url));
|
|
74
|
+
agent.remote = new RemotePod(cred, { log: () => {}, home: HOME });
|
|
75
|
+
// A pod that is not there is the ordinary case for an identity nobody has
|
|
76
|
+
// run in a while, and it used to arrive as an unhandled rejection — 20
|
|
77
|
+
// lines of undici internals ending in ECONNREFUSED, with the reason on the
|
|
78
|
+
// last line. Say which pod and why, and stop.
|
|
79
|
+
try { await agent.remote.warmup(); }
|
|
80
|
+
catch (e) {
|
|
81
|
+
const why = e?.cause?.code === 'ECONNREFUSED' ? 'nothing is listening there' : (e.message || String(e));
|
|
82
|
+
console.error(`cannot reach the pod this identity keeps its private half on:`);
|
|
83
|
+
console.error(` ${cred.remotePod} — ${why}`);
|
|
84
|
+
console.error('Nothing was copied and nothing was repointed. Start the pod and try again.');
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const destCred = { ...cred, privateRoot: target };
|
|
89
|
+
const from = agent.privateUrls(cred);
|
|
90
|
+
const dest = agent.privateUrls(destCred);
|
|
91
|
+
console.log(`moving the private half\n from ${from.state.replace(/ap-state\/$/, '')}\n to ${dest.state.replace(/ap-state\/$/, '')}\n`);
|
|
92
|
+
|
|
93
|
+
const { copyPrivateHalf } = await import(new URL('../../../../lib/device/migrate.mjs', import.meta.url));
|
|
94
|
+
let copied;
|
|
95
|
+
try {
|
|
96
|
+
copied = await copyPrivateHalf({
|
|
97
|
+
from: { state: agent.privateStorage(cred, 'state') },
|
|
98
|
+
to: { state: agent.privateStorage(destCred, 'state') },
|
|
99
|
+
log: (...a) => console.log('[state]', ...a),
|
|
100
|
+
});
|
|
101
|
+
} catch (e) { console.error(e.message); process.exit(1); }
|
|
102
|
+
console.log(`copied ${copied.docs} state document(s)`);
|
|
103
|
+
|
|
104
|
+
// An empty source produces an empty destination, and every check above
|
|
105
|
+
// passes: nothing failed to land because nothing was sent. The command then
|
|
106
|
+
// repointed the credential and said it had moved your private data. Say what
|
|
107
|
+
// actually happened instead — an empty move is usually a wrong --from, and
|
|
108
|
+
// finding that out later means looking for a timeline that was never there.
|
|
109
|
+
if (copied.docs === 0 && copied.notes === 0) {
|
|
110
|
+
console.log('\nNOTHING WAS COPIED — the source held no state documents and no notes.');
|
|
111
|
+
console.log(` from: ${where(cred)}`);
|
|
112
|
+
console.log('The pointer is being moved anyway, which is right for a fresh identity');
|
|
113
|
+
console.log('and wrong if you expected a timeline here. Check the source if so.\n');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (target) cred.privateRoot = target; else delete cred.privateRoot;
|
|
117
|
+
// Stamp what this install now IS, so `upgrade` stops naming it and the agent
|
|
118
|
+
// stops saying it is behind. Moving back onto the pod un-stamps it, which is
|
|
119
|
+
// honest rather than punitive: that is the old shape, and it should read as
|
|
120
|
+
// the old shape whoever chose it.
|
|
121
|
+
{
|
|
122
|
+
const { CURRENT_LAYOUT, isCurrent } = await import(new URL('../../../../lib/device/migrate.mjs', import.meta.url));
|
|
123
|
+
if (isCurrent(cred)) cred.layout = CURRENT_LAYOUT; else delete cred.layout;
|
|
124
|
+
}
|
|
125
|
+
writeJsonAtomic(credPath, cred);
|
|
126
|
+
console.log(`\nprivate data now: ${where(cred)}`);
|
|
127
|
+
console.log('The old copy was left where it was — `state --drop-remote` removes it');
|
|
128
|
+
console.log('once you are satisfied, or delete it by hand.');
|
|
129
|
+
process.exit(0);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function stateAll() {
|
|
133
|
+
// The root-wide half. `state --to` moves ONE identity, with the right AP_HOME
|
|
134
|
+
// set by hand; this runs over every identity under the root.
|
|
135
|
+
//
|
|
136
|
+
// Per identity, in its own process. HOME, PORT and the root are resolved at
|
|
137
|
+
// module load from the environment, so doing several in one process would
|
|
138
|
+
// mean re-deriving all of it — and a failure part way would be sharing state
|
|
139
|
+
// with the next one. A spawn per identity is a handful of processes for a
|
|
140
|
+
// one-shot migration, and each is exactly the command you could have typed.
|
|
141
|
+
const { execFile } = await import('node:child_process');
|
|
142
|
+
const { needsStateMove, pendingSteps } = await import(new URL('../../../../lib/device/migrate.mjs', import.meta.url));
|
|
143
|
+
const { apUrls } = await import(new URL('../../../../lib/core/wire.mjs', import.meta.url));
|
|
144
|
+
|
|
145
|
+
const homes = identityHomes(AP_ROOT);
|
|
146
|
+
if (!homes.length) {
|
|
147
|
+
console.log(`no identities under ${tildify(AP_ROOT)} — nothing to move`);
|
|
148
|
+
process.exit(0);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The same refusal `--to` and `home --restructure` make, for the same reason,
|
|
152
|
+
// and it has to cover ALL of them: a running agent holds its state
|
|
153
|
+
// write-through in memory, so a copy taken underneath one is overwritten by
|
|
154
|
+
// its next write.
|
|
155
|
+
// Only when something is actually going to be written. A dry run reads
|
|
156
|
+
// credential files and prints what it found, and refusing to do THAT while
|
|
157
|
+
// the agents are up defeats the whole point of leading with the inventory:
|
|
158
|
+
// you would have to stop everything to find out whether you needed to.
|
|
159
|
+
if (has('apply')) {
|
|
160
|
+
const live = [];
|
|
161
|
+
for (const { name, dir } of homes) {
|
|
162
|
+
let port = null;
|
|
163
|
+
try { port = JSON.parse(fs.readFileSync(path.join(dir, 'agent.json'), 'utf8')).port; } catch { continue; }
|
|
164
|
+
if (!port) continue;
|
|
165
|
+
const why = await somethingOn(port);
|
|
166
|
+
if (why) live.push(`${name} on ${port} (${why})`);
|
|
167
|
+
}
|
|
168
|
+
if (live.length) {
|
|
169
|
+
console.error(`still answering: ${live.join(', ')}`);
|
|
170
|
+
console.error('stop them first — a running agent would overwrite the copy with what it holds');
|
|
171
|
+
process.exit(2);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const rows = [];
|
|
176
|
+
for (const { name, dir } of homes) {
|
|
177
|
+
let cred = null;
|
|
178
|
+
try { cred = JSON.parse(fs.readFileSync(path.join(dir, 'credential.json'), 'utf8')); } catch { continue; }
|
|
179
|
+
rows.push({ name, dir, cred, pending: pendingSteps(cred) });
|
|
180
|
+
}
|
|
181
|
+
if (!rows.length) { console.log('no identities with a credential yet'); process.exit(0); }
|
|
182
|
+
|
|
183
|
+
if (has('drop-remote')) {
|
|
184
|
+
// Deliberately separate from the move, and second. While both copies exist
|
|
185
|
+
// the move is reversible; this is the step that ends that, so it is never
|
|
186
|
+
// something you get by accident.
|
|
187
|
+
const { RemotePod } = await import(new URL('../../../../lib/device/remote.mjs', import.meta.url));
|
|
188
|
+
const { classifyRemoteState } = await import(new URL('../../../../lib/device/migrate.mjs', import.meta.url));
|
|
189
|
+
const apply = has('apply');
|
|
190
|
+
for (const { name, dir, cred } of rows) {
|
|
191
|
+
if (needsStateMove(cred)) {
|
|
192
|
+
console.log(`${name}: still on the pod — run \`state --all --apply\` first`);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
const urls = apUrls(cred.remotePod, cred.root);
|
|
196
|
+
const remote = new RemotePod(cred, { log: () => {}, home: dir });
|
|
197
|
+
try { await remote.warmup(); } catch (e) { console.error(`${name}: ${e.message}`); continue; }
|
|
198
|
+
const children = (await remote.listContainer(urls.state)).map(c => c.url);
|
|
199
|
+
const { drop, keep } = classifyRemoteState(children, urls.state);
|
|
200
|
+
console.log(`${name}: ${drop.length} document(s) to remove, ${keep.length} kept`);
|
|
201
|
+
for (const k of keep) console.log(` keep ${k.name} — ${k.why}`);
|
|
202
|
+
for (const d of drop) {
|
|
203
|
+
if (!apply) { console.log(` would remove ${d.name}`); continue; }
|
|
204
|
+
try { await remote.delete(d.url); console.log(` removed ${d.name}`); }
|
|
205
|
+
catch (e) { console.error(` ${d.name}: ${e.message}`); }
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (!apply) console.log('\nThis was a dry run. Add --apply to remove them.');
|
|
209
|
+
process.exit(0);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const apply = has('apply');
|
|
213
|
+
console.log(apply ? 'Moving the private half onto this machine.\n'
|
|
214
|
+
: 'What a move would do. Nothing is written without --apply.\n');
|
|
215
|
+
let moved = 0;
|
|
216
|
+
for (const { name, dir, cred, pending } of rows) {
|
|
217
|
+
const home = cred.privateRoot ? tildify(cred.privateRoot)
|
|
218
|
+
: `${apUrls(cred.remotePod, cred.root).home} (ON THE POD)`;
|
|
219
|
+
if (!pending.length) { console.log(`${name}: already current — ${home}`); continue; }
|
|
220
|
+
const dest = pathToFileURL(path.join(dir, 'private')).href + '/';
|
|
221
|
+
if (!apply) {
|
|
222
|
+
console.log(`${name}: ${home}\n → would move to ${tildify(dest)}`);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
const out = await new Promise((resolve) => {
|
|
226
|
+
execFile(process.execPath, [process.argv[1], 'state', '--to', dest],
|
|
227
|
+
{ env: { ...process.env, AP_HOME: dir, AP_PROFILE: '' } },
|
|
228
|
+
(err, stdout, stderr) => resolve({ ok: !err, text: String(stdout) + String(stderr) }));
|
|
229
|
+
});
|
|
230
|
+
console.log(`${name}: ${out.ok ? 'moved' : 'FAILED'}`);
|
|
231
|
+
for (const line of out.text.split('\n').filter(Boolean)) console.log(` ${line}`);
|
|
232
|
+
if (out.ok) moved++;
|
|
233
|
+
}
|
|
234
|
+
if (apply) {
|
|
235
|
+
console.log(`\n${moved} identit(ies) moved. The pod still holds the old copy —`);
|
|
236
|
+
console.log('`state --drop-remote` removes it once you are satisfied.');
|
|
237
|
+
} else {
|
|
238
|
+
console.log('\nRe-run with --apply to do it.');
|
|
239
|
+
}
|
|
240
|
+
process.exit(0);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export async function upgrade() {
|
|
244
|
+
// One runner, every identity. The point is that being behind is a thing you
|
|
245
|
+
// can ASK about rather than something you find out from a pod bill.
|
|
246
|
+
const { pendingSteps, layoutOf, CURRENT_LAYOUT, isCurrent } =
|
|
247
|
+
await import(new URL('../../../../lib/device/migrate.mjs', import.meta.url));
|
|
248
|
+
const homes = identityHomes(AP_ROOT);
|
|
249
|
+
const behind = [];
|
|
250
|
+
for (const { name, dir } of homes) {
|
|
251
|
+
let cred = null;
|
|
252
|
+
try { cred = JSON.parse(fs.readFileSync(path.join(dir, 'credential.json'), 'utf8')); } catch { continue; }
|
|
253
|
+
const pending = pendingSteps(cred);
|
|
254
|
+
console.log(`${name}: layout ${layoutOf(cred)} of ${CURRENT_LAYOUT}${pending.length ? '' : ' — current'}`);
|
|
255
|
+
for (const s of pending) console.log(` ${s.id}: ${s.what}\n (${s.why})`);
|
|
256
|
+
// Nothing to do and never stamped: an install that was already in the right
|
|
257
|
+
// shape. Record it, so `upgrade` stops asking and the agent stops warning.
|
|
258
|
+
if (isCurrent(cred) && layoutOf(cred) < CURRENT_LAYOUT) {
|
|
259
|
+
writeJsonAtomic(path.join(dir, 'credential.json'), { ...cred, layout: CURRENT_LAYOUT });
|
|
260
|
+
console.log(' stamped as current');
|
|
261
|
+
}
|
|
262
|
+
if (pending.length) behind.push(name);
|
|
263
|
+
}
|
|
264
|
+
if (!homes.length) console.log(`no identities under ${tildify(AP_ROOT)}`);
|
|
265
|
+
else if (behind.length) {
|
|
266
|
+
console.log(`\n${behind.length} identit(ies) behind: ${behind.join(', ')}`);
|
|
267
|
+
console.log('The only step is the state move. See what it would do:');
|
|
268
|
+
console.log(` ${path.basename(process.argv[1])} state --all`);
|
|
269
|
+
console.log('then re-run it with --apply.');
|
|
270
|
+
} else if (homes.length) console.log('\nEverything here is at the current layout.');
|
|
271
|
+
process.exit(0);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export async function profiles() {
|
|
275
|
+
// Local files only, plus a quick liveness probe: nothing here needs the pod.
|
|
276
|
+
const homes = identityHomes(AP_ROOT);
|
|
277
|
+
|
|
278
|
+
const rows = [];
|
|
279
|
+
for (const { name, dir } of homes) {
|
|
280
|
+
let pod = null, port = null;
|
|
281
|
+
try { pod = JSON.parse(fs.readFileSync(path.join(dir, 'credential.json'), 'utf8')).remotePod; } catch {}
|
|
282
|
+
try { port = JSON.parse(fs.readFileSync(path.join(dir, 'agent.json'), 'utf8')).port; } catch {}
|
|
283
|
+
if (!pod && !port) continue; // not an identity, just a directory
|
|
284
|
+
let live = null;
|
|
285
|
+
if (port) {
|
|
286
|
+
live = await localFetch(HOME, port, `/status`, { signal: AbortSignal.timeout(1500) })
|
|
287
|
+
.then(r => r.json()).catch(() => null);
|
|
288
|
+
}
|
|
289
|
+
// The kind lives in pod state, so it is only knowable from the live probe —
|
|
290
|
+
// a stopped identity honestly shows nothing rather than a guess.
|
|
291
|
+
rows.push({ name, pod: pod ? new URL(pod).host : '(no credential)', port: port || '—',
|
|
292
|
+
kind: live?.kind === 'group' ? 'group' : live ? 'person' : '—',
|
|
293
|
+
state: live ? `${live.mode}${live.podRequests ? ` · ${live.podRequests.perMinuteNow}/min` : ''}` : 'not running' });
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Which one answers with no --profile. A property of the ROOT, not of any
|
|
297
|
+
// identity — which is the whole point of it being a pointer.
|
|
298
|
+
const theDefault = (() => { const d = defaultProfile(AP_ROOT); return typeof d === 'string' ? d : null; })();
|
|
299
|
+
if (!rows.length) console.log('no identities yet — fedipod setup');
|
|
300
|
+
else {
|
|
301
|
+
const w = (k, min) => Math.max(min, ...rows.map(r => String(r[k]).length));
|
|
302
|
+
const [wn, wp, wo, wk] = [w('name', 7), w('pod', 3), w('port', 4), w('kind', 4)];
|
|
303
|
+
console.log(`${'PROFILE'.padEnd(wn)} ${'POD'.padEnd(wp)} ${'PORT'.padEnd(wo)} ${'KIND'.padEnd(wk)} STATE`);
|
|
304
|
+
for (const r of rows) {
|
|
305
|
+
console.log(`${r.name.padEnd(wn)} ${String(r.pod).padEnd(wp)} ${String(r.port).padEnd(wo)}`
|
|
306
|
+
+ ` ${String(r.kind).padEnd(wk)} ${r.state}${r.name === theDefault ? ' (default)' : ''}`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
console.log(`\nIdentities under a custom AP_HOME are not listed — only ${AP_ROOT}`
|
|
310
|
+
+ ' and its profiles/*.');
|
|
311
|
+
process.exit(0);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export async function home() {
|
|
315
|
+
if (has('restructure')) return homeRestructure();
|
|
316
|
+
// The root holds the credential and the signing keys of every identity on
|
|
317
|
+
// this machine, so taking the post-rename name is a command you run, never
|
|
318
|
+
// something an upgrade does behind you. Resolution is in lib/home.mjs.
|
|
319
|
+
const to = flag('to');
|
|
320
|
+
const overridden = !!(process.env.AP_HOME || flag('home'));
|
|
321
|
+
|
|
322
|
+
if (!to) {
|
|
323
|
+
console.log(`\nroot: ${tildify(AP_ROOT)}${isLegacyRoot(AP_ROOT) ? ' (the name from before the rename)' : ''}`);
|
|
324
|
+
console.log(`this home: ${tildify(HOME)}`);
|
|
325
|
+
const d = defaultProfile(AP_ROOT);
|
|
326
|
+
for (const { name, dir } of identityHomes(AP_ROOT)) {
|
|
327
|
+
if (fs.existsSync(path.join(dir, 'credential.json'))) {
|
|
328
|
+
console.log(` · ${name}${name === d ? ' (default)' : ''}`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (rootHoldsIdentity(AP_ROOT)) {
|
|
332
|
+
console.log('\nThis root still keeps an identity at its top level, from before every');
|
|
333
|
+
console.log('identity moved under profiles/. Move it down with:\n');
|
|
334
|
+
console.log(` ${process.argv[1]} home --restructure\n`);
|
|
335
|
+
}
|
|
336
|
+
if (overridden) {
|
|
337
|
+
console.log('\nAP_HOME / --home is set, so this run is not using the root above.');
|
|
338
|
+
} else if (isLegacyRoot(AP_ROOT)) {
|
|
339
|
+
console.log('\nTake the current name with:\n');
|
|
340
|
+
console.log(` ${process.argv[1]} home --to ${path.join(os.homedir(), CURRENT_ROOT)}\n`);
|
|
341
|
+
console.log('That moves the whole root — the default identity and every profile — and');
|
|
342
|
+
console.log('refuses while any of them is answering.');
|
|
343
|
+
}
|
|
344
|
+
process.exit(0);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (overridden) {
|
|
348
|
+
console.error('AP_HOME / --home is set. That is an explicit directory, not the root this');
|
|
349
|
+
console.error('command moves — unset it and run again, or move the directory yourself.');
|
|
350
|
+
process.exit(2);
|
|
351
|
+
}
|
|
352
|
+
const target = path.resolve(to.replace(/^~(?=[/\\]|$)/, os.homedir()));
|
|
353
|
+
if (target === AP_ROOT) { console.log(`already there: ${AP_ROOT}`); process.exit(0); }
|
|
354
|
+
if (!fs.existsSync(AP_ROOT)) { console.error(`nothing to move — ${AP_ROOT} does not exist`); process.exit(2); }
|
|
355
|
+
if (fs.existsSync(target) && fs.readdirSync(target).length) {
|
|
356
|
+
console.error(`${target} exists and is not empty — refusing to merge two roots`);
|
|
357
|
+
process.exit(2);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// A running agent holds its pidfile and log by path; moving out from under it
|
|
361
|
+
// strands both and leaves `stop` with nothing to find.
|
|
362
|
+
const live = [];
|
|
363
|
+
for (const { name, dir } of identityHomes(AP_ROOT)) {
|
|
364
|
+
let port = null;
|
|
365
|
+
try { port = JSON.parse(fs.readFileSync(path.join(dir, 'agent.json'), 'utf8')).port; } catch { continue; }
|
|
366
|
+
if (port && await agentOn(port)) live.push(`${name} on ${port}`);
|
|
367
|
+
}
|
|
368
|
+
if (live.length) {
|
|
369
|
+
console.error(`still answering: ${live.join(', ')}`);
|
|
370
|
+
console.error('stop them first — a move would strand the pidfile and log they hold');
|
|
371
|
+
process.exit(2);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
375
|
+
try {
|
|
376
|
+
fs.renameSync(AP_ROOT, target);
|
|
377
|
+
} catch (e) {
|
|
378
|
+
if (e.code !== 'EXDEV') throw e; // a different filesystem
|
|
379
|
+
fs.cpSync(AP_ROOT, target, { recursive: true, preserveTimestamps: true });
|
|
380
|
+
fs.rmSync(AP_ROOT, { recursive: true, force: true });
|
|
381
|
+
}
|
|
382
|
+
console.log(`moved ${AP_ROOT} → ${target}`);
|
|
383
|
+
|
|
384
|
+
// privateRoot is recorded as an absolute path. One that pointed inside the
|
|
385
|
+
// root we just moved now points at nothing, and an agent finding an empty
|
|
386
|
+
// store reports itself unconfigured rather than saying why.
|
|
387
|
+
for (const { name, dir } of identityHomes(target)) {
|
|
388
|
+
const credPath = path.join(dir, 'credential.json');
|
|
389
|
+
let cred;
|
|
390
|
+
try { cred = JSON.parse(fs.readFileSync(credPath, 'utf8')); } catch { continue; }
|
|
391
|
+
if (!cred.privateRoot || /^https?:/i.test(cred.privateRoot)) continue; // a pod, not a directory
|
|
392
|
+
const was = cred.privateRoot.startsWith('file:')
|
|
393
|
+
? fileURLToPath(cred.privateRoot) : path.resolve(cred.privateRoot);
|
|
394
|
+
if (!isInside(AP_ROOT, was)) continue; // somewhere else entirely
|
|
395
|
+
const now = path.join(target, path.relative(AP_ROOT, was));
|
|
396
|
+
cred.privateRoot = pathToFileURL(now).href + '/';
|
|
397
|
+
writeJsonAtomic(credPath, cred);
|
|
398
|
+
console.log(` · ${name}: private data now ${cred.privateRoot}`);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
console.log('\nIf you installed the service, its unit has the old path baked in as');
|
|
402
|
+
console.log('Environment=AP_HOME — re-run install-service to update it.');
|
|
403
|
+
process.exit(0);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export async function homeRestructure() {
|
|
407
|
+
// One-time: move the identity that lives AT the root down into
|
|
408
|
+
// profiles/<name>/, so every identity is a named folder and none of them
|
|
409
|
+
// contains the others. Its own files only — profiles/ stays where it is.
|
|
410
|
+
if (process.env.AP_HOME || flag('home')) {
|
|
411
|
+
console.error('AP_HOME / --home is set. That is an explicit identity directory, not the');
|
|
412
|
+
console.error('root this restructures — unset it and run again.');
|
|
413
|
+
process.exit(2);
|
|
414
|
+
}
|
|
415
|
+
if (!rootHoldsIdentity(AP_ROOT)) {
|
|
416
|
+
console.log(`nothing to move — ${tildify(AP_ROOT)} keeps no identity at its top level`);
|
|
417
|
+
const d = defaultProfile(AP_ROOT);
|
|
418
|
+
if (typeof d === 'string') console.log(`default identity: ${d}`);
|
|
419
|
+
process.exit(0);
|
|
420
|
+
}
|
|
421
|
+
// Its name is its handle, which agent.json records and pod state confirms.
|
|
422
|
+
// Falling back to the credential's pod host keeps a half-set-up root movable.
|
|
423
|
+
let name = null;
|
|
424
|
+
try { name = JSON.parse(fs.readFileSync(path.join(AP_ROOT, 'agent.json'), 'utf8')).handle || null; } catch {}
|
|
425
|
+
if (!name) {
|
|
426
|
+
try {
|
|
427
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(AP_ROOT, 'private/ap-state/config.json'), 'utf8'));
|
|
428
|
+
name = cfg.handle || null;
|
|
429
|
+
} catch {}
|
|
430
|
+
}
|
|
431
|
+
name = flag('name', name);
|
|
432
|
+
if (!name) {
|
|
433
|
+
console.error('cannot tell what this identity is called — no handle in agent.json or pod state.');
|
|
434
|
+
console.error(`Name it: ${process.argv[1]} home --restructure --name <name>`);
|
|
435
|
+
process.exit(2);
|
|
436
|
+
}
|
|
437
|
+
requireHandle(name);
|
|
438
|
+
const dest = profileHome(AP_ROOT, name);
|
|
439
|
+
if (fs.existsSync(dest) && fs.readdirSync(dest).length) {
|
|
440
|
+
console.error(`${tildify(dest)} exists and is not empty — refusing to merge two identities`);
|
|
441
|
+
console.error(`Give the moved one another name: ${process.argv[1]} home --restructure --name <name>`);
|
|
442
|
+
process.exit(2);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// A running agent holds its pidfile and log by path. Same refusal `--to` makes.
|
|
446
|
+
const live = [];
|
|
447
|
+
for (const { name: n, dir } of [{ name, dir: AP_ROOT }, ...identityHomes(AP_ROOT)]) {
|
|
448
|
+
let port = null;
|
|
449
|
+
try { port = JSON.parse(fs.readFileSync(path.join(dir, 'agent.json'), 'utf8')).port; } catch { continue; }
|
|
450
|
+
if (!port) continue;
|
|
451
|
+
// somethingOn, not agentOn: a gated agent answers 401 to an un-tokened
|
|
452
|
+
// /status, and this moves its private key out from under it.
|
|
453
|
+
const why = await somethingOn(port);
|
|
454
|
+
if (why) live.push(`${n} on ${port} (${why})`);
|
|
455
|
+
}
|
|
456
|
+
if (live.length) {
|
|
457
|
+
console.error(`still answering: ${live.join(', ')}`);
|
|
458
|
+
console.error('stop them first — a move would strand the pidfile and log they hold');
|
|
459
|
+
process.exit(2);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Its own files, named explicitly. Everything else at the root — profiles/,
|
|
463
|
+
// and root.json once it exists — belongs to the root and stays.
|
|
464
|
+
const MOVE = ['credential.json', 'keys.json', 'agent.json', 'agent.log', 'token.json',
|
|
465
|
+
'backoff.json', 'private'];
|
|
466
|
+
fs.mkdirSync(dest, { recursive: true, mode: 0o700 });
|
|
467
|
+
const moved = [];
|
|
468
|
+
for (const f of MOVE) {
|
|
469
|
+
const from = path.join(AP_ROOT, f);
|
|
470
|
+
if (!fs.existsSync(from)) continue;
|
|
471
|
+
fs.renameSync(from, path.join(dest, f));
|
|
472
|
+
moved.push(f);
|
|
473
|
+
}
|
|
474
|
+
// A pidfile names a process that was told to stop. Carrying it forward would
|
|
475
|
+
// point `stop` at a pid nobody owns.
|
|
476
|
+
fs.rmSync(path.join(AP_ROOT, 'agent.pid'), { force: true });
|
|
477
|
+
console.log(`moved ${moved.length} item(s) → ${tildify(dest)}`);
|
|
478
|
+
console.log(` ${moved.join(', ')}`);
|
|
479
|
+
|
|
480
|
+
// privateRoot is an absolute path; one that pointed at the root's private/
|
|
481
|
+
// now points at nothing, and an agent finding an empty store reports itself
|
|
482
|
+
// unconfigured rather than saying why.
|
|
483
|
+
const credPath = path.join(dest, 'credential.json');
|
|
484
|
+
try {
|
|
485
|
+
const cred = JSON.parse(fs.readFileSync(credPath, 'utf8'));
|
|
486
|
+
if (cred.privateRoot && !/^https?:/i.test(cred.privateRoot)) {
|
|
487
|
+
const was = cred.privateRoot.startsWith('file:')
|
|
488
|
+
? fileURLToPath(cred.privateRoot) : path.resolve(cred.privateRoot);
|
|
489
|
+
if (isInside(AP_ROOT, was) && !isInside(dest, was)) {
|
|
490
|
+
cred.privateRoot = pathToFileURL(path.join(dest, path.relative(AP_ROOT, was))).href + '/';
|
|
491
|
+
writeJsonAtomic(credPath, cred);
|
|
492
|
+
console.log(` private data now ${tildify(cred.privateRoot)}`);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
} catch { /* no credential to repoint */ }
|
|
496
|
+
|
|
497
|
+
recordLastUsed(AP_ROOT, name);
|
|
498
|
+
console.log(`\n${name} is what a plain command means now. Start another with`);
|
|
499
|
+
console.log(`\`${path.basename(process.argv[1])} --profile <name> start\` and that becomes the one instead.`);
|
|
500
|
+
process.exit(0);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
export async function exportCollectionsCmd() {
|
|
504
|
+
// The account's collections as paged Turtle AS2 collections,
|
|
505
|
+
// produced on demand: outbox and followers from the pod, inbox from the
|
|
506
|
+
// local archive. See lib/export-collections.mjs.
|
|
507
|
+
requireIdentity();
|
|
508
|
+
const format = flag('format') || args[1];
|
|
509
|
+
const to = flag('to');
|
|
510
|
+
if (format !== 'as-collections' || !to) {
|
|
511
|
+
console.error('usage: fedipod export --format as-collections --to <directory-or-container-url>');
|
|
512
|
+
process.exit(2);
|
|
513
|
+
}
|
|
514
|
+
const { Agent } = await import(new URL('../../../../run-agent.mjs', import.meta.url));
|
|
515
|
+
const { RemotePod } = await import(new URL('../../../../lib/device/remote.mjs', import.meta.url));
|
|
516
|
+
const { apUrls } = await import(new URL('../../../../lib/core/wire.mjs', import.meta.url));
|
|
517
|
+
const { exportCollections, DIR_BASE } = await import(new URL('../../../../lib/device/export-collections.mjs', import.meta.url));
|
|
518
|
+
const { storageFor } = await import(new URL('../../../../lib/core/storage.mjs', import.meta.url));
|
|
519
|
+
const agent = new Agent({ home: HOME, log: () => {} });
|
|
520
|
+
const cred = agent.readCredential();
|
|
521
|
+
if (!cred) { console.error('no credential — run setup first'); process.exit(2); }
|
|
522
|
+
agent.remote = new RemotePod(cred);
|
|
523
|
+
await agent.remote.warmup();
|
|
524
|
+
agent.urls = apUrls(cred.remotePod, cred.root);
|
|
525
|
+
agent.store.attach(agent.privateStorage(cred, 'state'));
|
|
526
|
+
await agent.store.load();
|
|
527
|
+
const { Publisher } = await import(new URL('../../../../lib/core/publisher/index.mjs', import.meta.url));
|
|
528
|
+
const outboxItems = (await Publisher.prototype.readPublishedOutbox.call(
|
|
529
|
+
{ remote: agent.remote, urls: agent.urls })) || [];
|
|
530
|
+
// Only real AP actors, like the published followers collection.
|
|
531
|
+
const contacts = agent.store.getContacts();
|
|
532
|
+
const followers = contacts.followers.filter(f => !f.bsky).map(f => f.actor);
|
|
533
|
+
const archive = agent.privateStorage(cred, 'archive');
|
|
534
|
+
const inboxEntries = [];
|
|
535
|
+
const months = (await archive.list('').catch(() => ({ names: [] }))).names || [];
|
|
536
|
+
for (const m of months.filter(n => n.endsWith('/'))) {
|
|
537
|
+
const files = (await archive.list(m).catch(() => ({ names: [] }))).names || [];
|
|
538
|
+
for (const f of files) {
|
|
539
|
+
const r = await archive.read(m + f);
|
|
540
|
+
if (!r.ok) continue;
|
|
541
|
+
try { inboxEntries.push(JSON.parse(r.body)); } catch { /* not an archive record */ }
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
const isUrl = /^https?:/i.test(to);
|
|
545
|
+
const storage = storageFor(to, (u, i) => agent.remote.fetch(u, i));
|
|
546
|
+
const base = isUrl ? (to.endsWith('/') ? to : to + '/') : DIR_BASE;
|
|
547
|
+
try {
|
|
548
|
+
const out = await exportCollections({
|
|
549
|
+
outboxItems, followers, inboxEntries, storage, base, log: console.log,
|
|
550
|
+
resolve: (u) => agent.remote.getJson(u),
|
|
551
|
+
});
|
|
552
|
+
console.log(`outbox: ${out.outbox.items} item(s) in ${out.outbox.pages} page(s); `
|
|
553
|
+
+ `followers: ${out.followers.items}; inbox: ${out.inbox.items} archived item(s) in ${out.inbox.pages} page(s)`);
|
|
554
|
+
console.log(`${out.written} Turtle document(s) written to ${to}`);
|
|
555
|
+
} catch (e) {
|
|
556
|
+
console.error(`export failed: ${e.message}`);
|
|
557
|
+
process.exit(1);
|
|
558
|
+
}
|
|
559
|
+
}
|