agmsg-cloud 0.1.0-rc.7 → 0.1.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/dist/src/api.js +85 -3
- package/dist/src/commands/connect.js +14 -7
- package/dist/src/commands/login.js +119 -2
- package/dist/src/commands/logout.js +38 -7
- package/dist/src/commands/request.js +142 -4
- package/dist/src/commands/sync.js +51 -5
- package/dist/src/commands/vault.js +345 -78
- package/dist/src/commands/watch.js +25 -7
- package/dist/src/config.js +11 -2
- package/dist/src/credentials.js +149 -17
- package/dist/src/data-plane.js +67 -0
- package/dist/src/index.js +63 -27
- package/dist/src/oss.js +30 -1
- package/dist/src/preflight.js +140 -15
- package/dist/src/recovery-key.js +24 -21
- package/dist/src/self-install.js +152 -0
- package/dist/src/slot-advice.js +16 -22
- package/dist/src/vault-inventory.js +118 -0
- package/dist/src/vault-placement.js +94 -0
- package/dist/src/version.js +21 -22
- package/package.json +3 -2
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Join the container against what the server calls each team.
|
|
3
|
+
*
|
|
4
|
+
* BOTH SETS COME OUT OF ONE RESPONSE. `listTeams` answers with the ids it
|
|
5
|
+
* returned and which of those carry a null name, so "in this org without a
|
|
6
|
+
* name" and "not in this org" are derived from the same read rather than from
|
|
7
|
+
* two lookups that could disagree. Deriving them separately is how two
|
|
8
|
+
* existence checks get mistaken for a relation.
|
|
9
|
+
*/
|
|
10
|
+
export function inventoryOf(container, names) {
|
|
11
|
+
const total = container.teams.length;
|
|
12
|
+
if (!names.asked)
|
|
13
|
+
return { namesKnown: false, total, entries: [...container.teams] };
|
|
14
|
+
const named = [];
|
|
15
|
+
const unnamed = [];
|
|
16
|
+
const unknown = [];
|
|
17
|
+
const ambiguous = [];
|
|
18
|
+
// The ids the org answered with — the set membership test for `unknown`.
|
|
19
|
+
const returned = new Set(names.teams.map((t) => t.teamId));
|
|
20
|
+
const byId = new Map(names.teams.map((t) => [t.teamId, t.teamName]));
|
|
21
|
+
// How many entries each id accounts for. Counted over the CONTAINER, not the
|
|
22
|
+
// response: the response cannot hold the same id twice, which is the whole
|
|
23
|
+
// reason a name from it cannot resolve a repeat here.
|
|
24
|
+
const perId = new Map();
|
|
25
|
+
for (const entry of container.teams) {
|
|
26
|
+
perId.set(entry.team_id, (perId.get(entry.team_id) ?? 0) + 1);
|
|
27
|
+
}
|
|
28
|
+
for (const entry of container.teams) {
|
|
29
|
+
// Before the name is consulted at all. An id the vault holds twice has no
|
|
30
|
+
// name that means one thing, so asking for one and then discarding it would
|
|
31
|
+
// be the same mistake with an extra step.
|
|
32
|
+
if ((perId.get(entry.team_id) ?? 0) > 1) {
|
|
33
|
+
ambiguous.push(entry);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (!returned.has(entry.team_id)) {
|
|
37
|
+
unknown.push(entry);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const team = byId.get(entry.team_id);
|
|
41
|
+
// An empty string is not a name: it fails `agmsg_validate_team_name` ("must
|
|
42
|
+
// not be empty") and would reach the OSS side as a missing argument. It is
|
|
43
|
+
// the null case wearing a different type, so it lands in the same bucket.
|
|
44
|
+
if (team !== null && team !== undefined && team !== '')
|
|
45
|
+
named.push({ entry, team });
|
|
46
|
+
else
|
|
47
|
+
unnamed.push(entry);
|
|
48
|
+
}
|
|
49
|
+
// A NAME can repeat too, and a repeat is just as unplaceable. Team names are
|
|
50
|
+
// not unique on the server — `resolveTeamByName` refuses a name that matches
|
|
51
|
+
// more than one team for exactly this reason — so two entries with different
|
|
52
|
+
// ids can both come back called `alpha`. Only one local team can carry that
|
|
53
|
+
// name, and nothing in the response chooses between them: placing the first
|
|
54
|
+
// and letting the second collide is not a resolution, it is a race decided by
|
|
55
|
+
// the order the vault happens to store them in.
|
|
56
|
+
const perName = new Map();
|
|
57
|
+
for (const n of named)
|
|
58
|
+
perName.set(n.team, (perName.get(n.team) ?? 0) + 1);
|
|
59
|
+
const uniquelyNamed = [];
|
|
60
|
+
for (const n of named) {
|
|
61
|
+
if ((perName.get(n.team) ?? 0) > 1)
|
|
62
|
+
ambiguous.push(n.entry);
|
|
63
|
+
else
|
|
64
|
+
uniquelyNamed.push(n);
|
|
65
|
+
}
|
|
66
|
+
return { namesKnown: true, total, named: uniquelyNamed, unnamed, unknown, ambiguous };
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The report, as lines.
|
|
70
|
+
*
|
|
71
|
+
* Built as data rather than written to stdout so a test can read it without a
|
|
72
|
+
* process, and so the one rule that matters is checkable: **the count of what
|
|
73
|
+
* could not be done is always printed when it is not zero**, with the ids.
|
|
74
|
+
*/
|
|
75
|
+
export function inventoryLines(inv, revision) {
|
|
76
|
+
const lines = [`opened the account vault, revision ${revision}`];
|
|
77
|
+
if (inv.total === 0) {
|
|
78
|
+
lines.push('it holds no team backups yet — `agmsg-cloud recovery setup` creates them');
|
|
79
|
+
return lines;
|
|
80
|
+
}
|
|
81
|
+
lines.push(`${inv.total} team backup(s) in it`);
|
|
82
|
+
if (!inv.namesKnown) {
|
|
83
|
+
// Every id, and no verdict about any of them. The entries are here; what
|
|
84
|
+
// failed is the question that would say which are placeable, and answering
|
|
85
|
+
// it anyway is how a network error becomes "your backups are unreachable".
|
|
86
|
+
lines.push('the server could not be asked for names, so none of these can be sorted yet:');
|
|
87
|
+
for (const entry of inv.entries)
|
|
88
|
+
lines.push(` ${entry.team_id}`);
|
|
89
|
+
return lines;
|
|
90
|
+
}
|
|
91
|
+
for (const { team, entry } of inv.named) {
|
|
92
|
+
lines.push(` ${team} (${entry.team_id})`);
|
|
93
|
+
}
|
|
94
|
+
// Never folded together, and never folded into the count above. "4 of 6" that
|
|
95
|
+
// does not name the other two is the shape of a partial success printed as a
|
|
96
|
+
// success — and these two lists have different ways out, so a reader who is
|
|
97
|
+
// shown one line takes the worse of the two readings for all of them.
|
|
98
|
+
if (inv.unnamed.length > 0) {
|
|
99
|
+
lines.push(`${inv.unnamed.length} of ${inv.total} are in this org but the server has no name recorded` +
|
|
100
|
+
` for them. If you know the name, it can be placed:`);
|
|
101
|
+
for (const entry of inv.unnamed)
|
|
102
|
+
lines.push(` ${entry.team_id}`);
|
|
103
|
+
}
|
|
104
|
+
if (inv.ambiguous.length > 0) {
|
|
105
|
+
lines.push(`${inv.ambiguous.length} of ${inv.total} share a team id with another backup on a` +
|
|
106
|
+
` different server, and the server's team list cannot say which name belongs to which:`);
|
|
107
|
+
for (const entry of inv.ambiguous) {
|
|
108
|
+
lines.push(` ${entry.team_id} on ${entry.server_instance_id}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (inv.unknown.length > 0) {
|
|
112
|
+
lines.push(`${inv.unknown.length} of ${inv.total} did not appear in this org's teams at all —` +
|
|
113
|
+
` this machine's credential cannot reach them:`);
|
|
114
|
+
for (const entry of inv.unknown)
|
|
115
|
+
lines.push(` ${entry.team_id}`);
|
|
116
|
+
}
|
|
117
|
+
return lines;
|
|
118
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What happened to each entry the placement loop tried to place, and how that
|
|
3
|
+
* is reported.
|
|
4
|
+
*
|
|
5
|
+
* Kept as data and rendered separately for the same reason `vault-inventory.ts`
|
|
6
|
+
* is: the rule worth checking is about the REPORT, not about the spawning, and
|
|
7
|
+
* a test should be able to read the report without a process.
|
|
8
|
+
*
|
|
9
|
+
* The rule, from #342a and unchanged: **a partial result is never printed as a
|
|
10
|
+
* success.** Anything not placed is listed, with its id.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* @param inVault every entry the vault holds, NOT just the ones that were
|
|
14
|
+
* attempted. `placed 1 of 1` printed under `2 team backup(s) in it` is a
|
|
15
|
+
* partial result reading as a complete one — the denominator has to be the
|
|
16
|
+
* thing the person came here to get back.
|
|
17
|
+
*/
|
|
18
|
+
export function placementLines(results, inVault) {
|
|
19
|
+
if (results.length === 0)
|
|
20
|
+
return [];
|
|
21
|
+
const lines = [];
|
|
22
|
+
const placed = results.filter((r) => r.kind === 'placed');
|
|
23
|
+
const already = results.filter((r) => r.kind === 'already');
|
|
24
|
+
const done = placed.length + already.length;
|
|
25
|
+
lines.push(`placed ${done} of ${inVault} team backup(s) in the vault`);
|
|
26
|
+
if (results.length < inVault) {
|
|
27
|
+
// Said here as well as in the inventory above, because this line is the one
|
|
28
|
+
// a person reads as the outcome, and a denominator it cannot account for
|
|
29
|
+
// invites the reading that the rest simply worked.
|
|
30
|
+
lines.push(` (${inVault - results.length} of those could not be attempted — see the list above)`);
|
|
31
|
+
}
|
|
32
|
+
for (const r of placed)
|
|
33
|
+
lines.push(` ${r.team} (${r.teamId})`);
|
|
34
|
+
for (const r of already) {
|
|
35
|
+
lines.push(` ${r.team} (${r.teamId}) — was already here, its key was re-imported`);
|
|
36
|
+
}
|
|
37
|
+
const stuck = results.filter((r) => r.kind === 'name-held' ||
|
|
38
|
+
r.kind === 'refused' ||
|
|
39
|
+
r.kind === 'locked' ||
|
|
40
|
+
r.kind === 'disconnected' ||
|
|
41
|
+
r.kind === 'wrong-instance' ||
|
|
42
|
+
r.kind === 'server-unknown');
|
|
43
|
+
if (stuck.length === 0)
|
|
44
|
+
return lines;
|
|
45
|
+
lines.push(`${stuck.length} could not be placed:`);
|
|
46
|
+
for (const r of stuck) {
|
|
47
|
+
if (r.kind === 'disconnected') {
|
|
48
|
+
lines.push(` ${r.team} (${r.teamId})`);
|
|
49
|
+
lines.push(' already on this machine, but its connection to the server was ended,');
|
|
50
|
+
lines.push(' so its key cannot be imported. Reconnect it, then run this again.');
|
|
51
|
+
}
|
|
52
|
+
else if (r.kind === 'server-unknown') {
|
|
53
|
+
lines.push(` ${r.team} (${r.teamId})`);
|
|
54
|
+
lines.push(' which server this machine reaches could not be established, so nothing');
|
|
55
|
+
lines.push(' was pulled — placing it without knowing would risk taking the wrong one:');
|
|
56
|
+
lines.push(` ${r.why}`);
|
|
57
|
+
}
|
|
58
|
+
else if (r.kind === 'wrong-instance') {
|
|
59
|
+
lines.push(` ${r.team} (${r.teamId})`);
|
|
60
|
+
lines.push(' this backup is from a different server than the one this machine');
|
|
61
|
+
lines.push(' reaches, so nothing was pulled and nothing was written:');
|
|
62
|
+
lines.push(` the backup's server: ${r.wanted}`);
|
|
63
|
+
lines.push(` the server reached: ${r.got}`);
|
|
64
|
+
lines.push(' no local team was created, so the name is still free for the right one.');
|
|
65
|
+
}
|
|
66
|
+
else if (r.kind === 'name-held') {
|
|
67
|
+
// The facts, and no route. Both ids so the two teams can be told apart,
|
|
68
|
+
// and "nothing was changed" said outright: the worst reading of a bare
|
|
69
|
+
// refusal is that something was half-done.
|
|
70
|
+
lines.push(` ${r.team} (${r.teamId})`);
|
|
71
|
+
lines.push(` a different team on this machine already holds the name '${r.team}'`);
|
|
72
|
+
// BOTH halves of both identities. The two can share a team id and differ
|
|
73
|
+
// only by server, and a report printing one uuid twice says "different
|
|
74
|
+
// team" while showing nothing that differs.
|
|
75
|
+
lines.push(` the local one: ${r.localTeamId} on ${r.localInstance}`);
|
|
76
|
+
lines.push(` the backup: ${r.teamId} on ${r.instance}`);
|
|
77
|
+
lines.push(' nothing local was changed. Placing it needs that name to be free.');
|
|
78
|
+
}
|
|
79
|
+
else if (r.kind === 'locked') {
|
|
80
|
+
lines.push(` ${r.team} (${r.teamId})`);
|
|
81
|
+
lines.push(' it is on this machine but still locked — its key did not import:');
|
|
82
|
+
for (const said of r.said.split('\n'))
|
|
83
|
+
if (said.trim() !== '')
|
|
84
|
+
lines.push(` ${said}`);
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
lines.push(` ${r.team} (${r.teamId})`);
|
|
88
|
+
for (const said of r.said.split('\n'))
|
|
89
|
+
if (said.trim() !== '')
|
|
90
|
+
lines.push(` ${said}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return lines;
|
|
94
|
+
}
|
package/dist/src/version.js
CHANGED
|
@@ -5,27 +5,23 @@ import { fileURLToPath } from 'node:url';
|
|
|
5
5
|
// version — an identity, and the only thing that tells one package.json from
|
|
6
6
|
// another while walking up a tree that may contain several.
|
|
7
7
|
const PACKAGE_NAME = 'agmsg-cloud';
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
// stripped of its version is exactly when a monorepo root, an npx cache entry,
|
|
26
|
-
// or a parent workspace supplies its own. Reporting a stranger's version is the
|
|
27
|
-
// failure this command exists to prevent, wearing the shape of a success.
|
|
28
|
-
export function packageVersion(moduleUrl = import.meta.url) {
|
|
8
|
+
/**
|
|
9
|
+
* The install this process is running out of: its version AND where it is.
|
|
10
|
+
*
|
|
11
|
+
* Both come from ONE walk, and that is the point rather than a convenience.
|
|
12
|
+
* `which agmsg-cloud` answers from PATH and a manifest answers from the module
|
|
13
|
+
* graph; when two package managers have both installed this tool, those are two
|
|
14
|
+
* different questions with two different answers, and pairing them would report
|
|
15
|
+
* a directory that need not be where the version came from. The directory
|
|
16
|
+
* returned here is the one whose `package.json` supplied the version, so the
|
|
17
|
+
* two cannot disagree.
|
|
18
|
+
*
|
|
19
|
+
* Node resolves a module's realpath before loading it, so a bin symlink — the
|
|
20
|
+
* usual shape of a global install — has already been followed by the time
|
|
21
|
+
* `import.meta.url` exists. What comes back is the store directory, which is
|
|
22
|
+
* the part that tells a pnpm global install from an npm one.
|
|
23
|
+
*/
|
|
24
|
+
export function packageInstall(moduleUrl = import.meta.url) {
|
|
29
25
|
let dir = dirname(fileURLToPath(moduleUrl));
|
|
30
26
|
const { root } = parse(dir);
|
|
31
27
|
for (;;) {
|
|
@@ -41,7 +37,7 @@ export function packageVersion(moduleUrl = import.meta.url) {
|
|
|
41
37
|
if (manifest?.name === PACKAGE_NAME) {
|
|
42
38
|
const version = manifest.version;
|
|
43
39
|
if (typeof version === 'string' && version !== '')
|
|
44
|
-
return version;
|
|
40
|
+
return { version, directory: dir };
|
|
45
41
|
// Fail closed. This IS the package and it cannot say what it is; climbing
|
|
46
42
|
// past would hand the question to whatever sits above, which is how a
|
|
47
43
|
// broken install comes to announce a stranger's version.
|
|
@@ -55,3 +51,6 @@ export function packageVersion(moduleUrl = import.meta.url) {
|
|
|
55
51
|
// guessed — see the note above.
|
|
56
52
|
throw new Error(`cannot determine the installed version: no ${PACKAGE_NAME} package.json above this module`);
|
|
57
53
|
}
|
|
54
|
+
export function packageVersion(moduleUrl = import.meta.url) {
|
|
55
|
+
return packageInstall(moduleUrl).version;
|
|
56
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agmsg-cloud",
|
|
3
|
-
"version": "0.1.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "Companion CLI for the agmsg cloud service: connect a team, join from another machine, and back up its keys.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
@@ -34,7 +34,8 @@
|
|
|
34
34
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
35
35
|
"lint": "tsx scripts/check-printed-commands.ts && tsx scripts/check-dist-is-current.ts && tsx scripts/check-readme-commands.ts && tsx scripts/check-handles.ts",
|
|
36
36
|
"prepack": "pnpm run build && tsx scripts/check-dist-is-current.ts && tsx scripts/check-readme-commands.ts && tsx scripts/check-handles.ts",
|
|
37
|
-
"verify:published": "tsx scripts/verify-published.ts"
|
|
37
|
+
"verify:published": "tsx scripts/verify-published.ts",
|
|
38
|
+
"verify:shown-install": "tsx scripts/verify-shown-install.ts"
|
|
38
39
|
},
|
|
39
40
|
"dependencies": {
|
|
40
41
|
"@agmsg-cloud/sas-core": "workspace:*",
|