faces-cli 1.8.1 → 1.8.3
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/commands/catalog/doctor.js +63 -12
- package/dist/commands/face/get.js +17 -6
- package/dist/commands/style/make.js +15 -3
- package/dist/commands/style/revert.d.ts +1 -0
- package/dist/commands/style/revert.js +30 -17
- package/dist/commands/style/versions.js +25 -13
- package/dist/commands/team/add.d.ts +1 -0
- package/dist/commands/team/add.js +24 -8
- package/dist/commands/team/members.d.ts +1 -0
- package/dist/commands/team/members.js +53 -4
- package/dist/style.d.ts +6 -0
- package/dist/style.js +1 -0
- package/dist/team-catalog.d.ts +17 -0
- package/dist/team-catalog.js +25 -0
- package/dist/utils.d.ts +25 -0
- package/dist/utils.js +69 -3
- package/oclif.manifest.json +1553 -1537
- package/package.json +1 -1
|
@@ -5,7 +5,7 @@ import { BaseCommand } from '../../base.js';
|
|
|
5
5
|
import { FacesAPIError } from '../../client.js';
|
|
6
6
|
import { loadConfig } from '../../config.js';
|
|
7
7
|
import { CatalogService, CATALOG_DIR } from '../../catalog.js';
|
|
8
|
-
import { TeamCatalogService } from '../../team-catalog.js';
|
|
8
|
+
import { TeamCatalogService, TEAMS_DIR, slugifyTeamName } from '../../team-catalog.js';
|
|
9
9
|
import { resolveEndpoint, MESSAGES_ENDPOINT, RESPONSES_ENDPOINT } from '../../routing.js';
|
|
10
10
|
export default class CatalogDoctor extends BaseCommand {
|
|
11
11
|
static description = 'Diagnose and repair the local face and team catalog';
|
|
@@ -106,17 +106,33 @@ export default class CatalogDoctor extends BaseCommand {
|
|
|
106
106
|
}
|
|
107
107
|
// Check teams
|
|
108
108
|
let remoteTeams = [];
|
|
109
|
+
// Every team the account has, personal included. The sync pass skips the
|
|
110
|
+
// personal Guild, but orphan detection must not: a local copy of a team
|
|
111
|
+
// that was filtered out of the comparison looks deleted when it is not.
|
|
112
|
+
let allRemoteSlugs = new Set();
|
|
113
|
+
// Whether the server actually answered. Without this an unreachable API
|
|
114
|
+
// looks exactly like an account with no teams, and --fix would delete the
|
|
115
|
+
// entire local team catalog on a timeout.
|
|
116
|
+
let teamsFetched = false;
|
|
109
117
|
const localTeamNames = new Set(teamCatalog.listTeams());
|
|
110
118
|
try {
|
|
111
119
|
const resp = await client.get('/v1/me/teams');
|
|
112
|
-
|
|
113
|
-
|
|
120
|
+
const all = resp.data ?? resp;
|
|
121
|
+
allRemoteSlugs = new Set(all.map(t => slugifyTeamName(String(t.name))));
|
|
122
|
+
remoteTeams = all.filter(t => t.kind !== 'personal');
|
|
123
|
+
teamsFetched = true;
|
|
114
124
|
}
|
|
115
|
-
catch { /* proceed */ }
|
|
116
|
-
const missingTeams = remoteTeams.filter(t =>
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
125
|
+
catch { /* proceed; teamsFetched stays false */ }
|
|
126
|
+
const missingTeams = remoteTeams.filter(t => !localTeamNames.has(slugifyTeamName(String(t.name))));
|
|
127
|
+
// The mirror of the orphaned-face pass: present locally, gone from the server.
|
|
128
|
+
const orphanedTeams = teamsFetched
|
|
129
|
+
? [...localTeamNames].filter(name => !allRemoteSlugs.has(name))
|
|
130
|
+
: [];
|
|
131
|
+
// A deleted team's protocol survives only in TEAM.md, so anything with a
|
|
132
|
+
// body is reported rather than removed. Frontmatter alone is reproducible
|
|
133
|
+
// and safe to drop.
|
|
134
|
+
const removableTeams = orphanedTeams.filter(name => !teamCatalog.hasLocalContent(name));
|
|
135
|
+
const keptTeams = orphanedTeams.filter(name => teamCatalog.hasLocalContent(name));
|
|
120
136
|
const doFix = flags.fix || flags.generate;
|
|
121
137
|
if (!doFix) {
|
|
122
138
|
const issues = [];
|
|
@@ -130,6 +146,11 @@ export default class CatalogDoctor extends BaseCommand {
|
|
|
130
146
|
issues.push(`${noDescription.length} face(s) without a description`);
|
|
131
147
|
if (missingTeams.length > 0)
|
|
132
148
|
issues.push(`${missingTeams.length} team(s) missing from local catalog`);
|
|
149
|
+
if (removableTeams.length > 0)
|
|
150
|
+
issues.push(`${removableTeams.length} local team(s) no longer on the server`);
|
|
151
|
+
if (keptTeams.length > 0) {
|
|
152
|
+
issues.push(`${keptTeams.length} local team(s) no longer on the server with local content: ${keptTeams.join(', ')}`);
|
|
153
|
+
}
|
|
133
154
|
if (issues.length === 0) {
|
|
134
155
|
this.log('Catalog is healthy.');
|
|
135
156
|
}
|
|
@@ -142,7 +163,15 @@ export default class CatalogDoctor extends BaseCommand {
|
|
|
142
163
|
this.log('Run faces catalog:doctor --generate to also create missing descriptions');
|
|
143
164
|
}
|
|
144
165
|
}
|
|
145
|
-
return {
|
|
166
|
+
return {
|
|
167
|
+
missing: missing.length,
|
|
168
|
+
stale: stale.length,
|
|
169
|
+
orphaned: orphaned.length,
|
|
170
|
+
noDescription: noDescription.length,
|
|
171
|
+
missingTeams: missingTeams.length,
|
|
172
|
+
orphanedTeams: removableTeams.length,
|
|
173
|
+
orphanedTeamsKept: keptTeams,
|
|
174
|
+
};
|
|
146
175
|
}
|
|
147
176
|
// Fix mode: rebuild face entries
|
|
148
177
|
let fixed = 0;
|
|
@@ -172,7 +201,7 @@ export default class CatalogDoctor extends BaseCommand {
|
|
|
172
201
|
}
|
|
173
202
|
for (const team of remoteTeams) {
|
|
174
203
|
const teamId = team.id;
|
|
175
|
-
const slug = String(team.name)
|
|
204
|
+
const slug = slugifyTeamName(String(team.name));
|
|
176
205
|
// Fetch members and tags
|
|
177
206
|
let memberAliases = [];
|
|
178
207
|
try {
|
|
@@ -198,7 +227,22 @@ export default class CatalogDoctor extends BaseCommand {
|
|
|
198
227
|
}, team.protocol ?? undefined);
|
|
199
228
|
teamsSynced++;
|
|
200
229
|
}
|
|
201
|
-
|
|
230
|
+
// Remove orphaned teams, the mirror of the orphaned-face removal above.
|
|
231
|
+
let teamsRemoved = 0;
|
|
232
|
+
for (const name of removableTeams) {
|
|
233
|
+
teamCatalog.deleteTeam(name);
|
|
234
|
+
teamsRemoved++;
|
|
235
|
+
}
|
|
236
|
+
this.log(`Fixed ${fixed} face(s), removed ${removed} orphaned face(s) and ${teamsRemoved} orphaned team(s), ` +
|
|
237
|
+
`synced ${teamsSynced} team(s).`);
|
|
238
|
+
// Never let a partial clean-up read as a complete one.
|
|
239
|
+
for (const name of keptTeams) {
|
|
240
|
+
this.log(`Kept '${name}': the team is gone from the server but TEAM.md has content that exists nowhere else. ` +
|
|
241
|
+
`Remove it yourself with: rm -rf ${path.join(TEAMS_DIR, name)}`);
|
|
242
|
+
}
|
|
243
|
+
if (!teamsFetched) {
|
|
244
|
+
this.log('Could not reach the teams API, so local teams were not checked against the server.');
|
|
245
|
+
}
|
|
202
246
|
// Generate mode: create missing descriptions
|
|
203
247
|
if (flags.generate && noDescription.length > 0) {
|
|
204
248
|
const cfg = loadConfig();
|
|
@@ -262,6 +306,13 @@ export default class CatalogDoctor extends BaseCommand {
|
|
|
262
306
|
this.log(`Generated ${generated} description(s).`);
|
|
263
307
|
}
|
|
264
308
|
catalog.rebuildIndex();
|
|
265
|
-
return {
|
|
309
|
+
return {
|
|
310
|
+
fixed,
|
|
311
|
+
removed,
|
|
312
|
+
teamsRemoved,
|
|
313
|
+
teamsKept: keptTeams,
|
|
314
|
+
teamsSynced,
|
|
315
|
+
generated: flags.generate ? noDescription.length : 0,
|
|
316
|
+
};
|
|
266
317
|
}
|
|
267
318
|
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { Args, Flags } from '@oclif/core';
|
|
2
2
|
import { BaseCommand } from '../../base.js';
|
|
3
3
|
import { FacesAPIError } from '../../client.js';
|
|
4
|
-
import { renameFaceFields } from '../../utils.js';
|
|
4
|
+
import { renameFaceFields, resolveFace } from '../../utils.js';
|
|
5
5
|
export default class FaceGet extends BaseCommand {
|
|
6
|
-
static description = 'Get details for a face by alias'
|
|
6
|
+
static description = 'Get details for a face, by alias or owner:alias. A published or shared face is reachable under the ' +
|
|
7
|
+
'same owner:alias address chat uses; it is shown with a note that you do not own it.';
|
|
7
8
|
static flags = {
|
|
8
9
|
...BaseCommand.baseFlags,
|
|
9
10
|
include: Flags.string({ description: 'Include extra fields: tags, teams (comma-separated)' }),
|
|
@@ -20,16 +21,21 @@ export default class FaceGet extends BaseCommand {
|
|
|
20
21
|
if (flags.include)
|
|
21
22
|
for (const v of flags.include.split(','))
|
|
22
23
|
includes.add(v.trim());
|
|
23
|
-
|
|
24
|
-
let data;
|
|
24
|
+
let resolved;
|
|
25
25
|
try {
|
|
26
|
-
|
|
26
|
+
resolved = await resolveFace(client, args.face_id, [...includes].join(','));
|
|
27
27
|
}
|
|
28
28
|
catch (err) {
|
|
29
|
-
if (err instanceof FacesAPIError)
|
|
29
|
+
if (err instanceof FacesAPIError) {
|
|
30
|
+
if (err.statusCode === 404) {
|
|
31
|
+
this.error(`No face '${args.face_id}'. If it belongs to another account, name it as owner:alias, ` +
|
|
32
|
+
'the same address chat uses. Find it with: faces face:list --public');
|
|
33
|
+
}
|
|
30
34
|
this.error(`Error (${err.statusCode}): ${err.message}`);
|
|
35
|
+
}
|
|
31
36
|
throw err;
|
|
32
37
|
}
|
|
38
|
+
const data = resolved.face;
|
|
33
39
|
const face = data;
|
|
34
40
|
renameFaceFields(face);
|
|
35
41
|
const f = face;
|
|
@@ -38,6 +44,11 @@ export default class FaceGet extends BaseCommand {
|
|
|
38
44
|
this.log(`uid: ${f.uid}`);
|
|
39
45
|
this.log(`name: ${f.name}`);
|
|
40
46
|
this.log(`owned_by: ${f.owned_by}`);
|
|
47
|
+
// Say plainly that this is someone else's, and how it can be used.
|
|
48
|
+
if (!resolved.owned) {
|
|
49
|
+
this.log(`access: not yours; reachable because it is published or shared with you`);
|
|
50
|
+
this.log(`address: ${resolved.handle}@<model> (chat only; it cannot join a team or be edited)`);
|
|
51
|
+
}
|
|
41
52
|
this.log(`created: ${new Date(f.created * 1000).toISOString().slice(0, 10)}`);
|
|
42
53
|
if (f.read_only)
|
|
43
54
|
this.log('read only');
|
|
@@ -25,8 +25,8 @@ export default class StyleMake extends BaseCommand {
|
|
|
25
25
|
default: false,
|
|
26
26
|
}),
|
|
27
27
|
medium: Flags.string({
|
|
28
|
-
description: 'Medium for
|
|
29
|
-
'so an essay never teaches a rule about email.',
|
|
28
|
+
description: 'Medium for selected sources that do not already declare one. It does not override a source ' +
|
|
29
|
+
'that does. Each medium is analysed on its own, so an essay never teaches a rule about email.',
|
|
30
30
|
}),
|
|
31
31
|
model: Flags.string({ description: `Analyst model (default: ${DEFAULT_STYLE_MODEL})` }),
|
|
32
32
|
'allow-paid': Flags.boolean({
|
|
@@ -145,6 +145,13 @@ export default class StyleMake extends BaseCommand {
|
|
|
145
145
|
// A live thread is the exception. It compiles as its messages arrive
|
|
146
146
|
// and is billed per message, so sweeping an unfinished one would charge
|
|
147
147
|
// for turns the user did not send. Those wait to be named.
|
|
148
|
+
// The server reports how many messages a capture would actually read.
|
|
149
|
+
// Zero is a guaranteed intake failure, and one bad source fails the
|
|
150
|
+
// whole request, so a sweep must not pull them in.
|
|
151
|
+
if (s.authored === 0) {
|
|
152
|
+
skipped.push(`${s.label} (${s.id}) has nothing written by this person for a capture to read.`);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
148
155
|
if (s.isCorpus || s.synced) {
|
|
149
156
|
if (!flags.compile && !s.synced) {
|
|
150
157
|
skipped.push(`${s.label} (${s.id}) is not compiled, and --no-compile means it would teach nothing.`);
|
|
@@ -182,7 +189,12 @@ export default class StyleMake extends BaseCommand {
|
|
|
182
189
|
const undeclared = [];
|
|
183
190
|
const sources = [];
|
|
184
191
|
for (const { s, declared } of chosen) {
|
|
185
|
-
|
|
192
|
+
// Precedence: an explicit --source id:medium, then whatever the source
|
|
193
|
+
// itself declares, then --medium as a fallback for sources that say
|
|
194
|
+
// nothing. --medium must not override a source's own declaration: doing
|
|
195
|
+
// that silently resubmits a document that says "essay" as something else,
|
|
196
|
+
// and the stamp it leaves behind then misreports how it was read.
|
|
197
|
+
const medium = declared ?? s.medium ?? flags.medium;
|
|
186
198
|
if (!medium) {
|
|
187
199
|
undeclared.push(s);
|
|
188
200
|
continue;
|
|
@@ -3,6 +3,7 @@ export default class StyleRevert extends BaseCommand {
|
|
|
3
3
|
static description: string;
|
|
4
4
|
static examples: string[];
|
|
5
5
|
static flags: {
|
|
6
|
+
medium: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
6
7
|
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
7
8
|
'base-url': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
8
9
|
token: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
@@ -3,12 +3,18 @@ import { BaseCommand } from '../../base.js';
|
|
|
3
3
|
import { FacesAPIError } from '../../client.js';
|
|
4
4
|
import { VERSIONS_PATH } from '../../style.js';
|
|
5
5
|
export default class StyleRevert extends BaseCommand {
|
|
6
|
-
static description = 'Go back to the style a face had before the last style:make.
|
|
7
|
-
'
|
|
8
|
-
'It changes how the face writes immediately, so it asks first.';
|
|
9
|
-
static examples = [
|
|
6
|
+
static description = 'Go back to the style a face had before the last style:make. A face holds one style per medium, so ' +
|
|
7
|
+
'name which one with --medium when it has more than one. Two versions are kept per medium, so this ' +
|
|
8
|
+
'goes back exactly one step. It changes how the face writes immediately, so it asks first.';
|
|
9
|
+
static examples = [
|
|
10
|
+
'<%= config.bin %> <%= command.id %> alice --yes',
|
|
11
|
+
'<%= config.bin %> <%= command.id %> alice --medium email --yes',
|
|
12
|
+
];
|
|
10
13
|
static flags = {
|
|
11
14
|
...BaseCommand.baseFlags,
|
|
15
|
+
medium: Flags.string({
|
|
16
|
+
description: 'Which style to step back, when the face has more than one. See faces style:versions <alias>.',
|
|
17
|
+
}),
|
|
12
18
|
yes: Flags.boolean({ description: 'Skip confirmation', default: false }),
|
|
13
19
|
};
|
|
14
20
|
static args = {
|
|
@@ -21,26 +27,35 @@ export default class StyleRevert extends BaseCommand {
|
|
|
21
27
|
// not ceremony: the endpoint takes a single field and gives no dry run, so
|
|
22
28
|
// an exploratory call is indistinguishable from a real one.
|
|
23
29
|
if (!flags.yes) {
|
|
24
|
-
this.error(`This changes how '${args.alias}' writes
|
|
30
|
+
this.error(`This changes how '${args.alias}' writes${flags.medium ? ` for ${flags.medium}` : ''}, straight away. ` +
|
|
31
|
+
'Re-run with --yes to confirm.\n' +
|
|
25
32
|
`See what it would go back to: faces style:versions ${args.alias}`);
|
|
26
33
|
}
|
|
34
|
+
const body = { self_face: args.alias };
|
|
35
|
+
// A medium is required once a face holds more than one style. Sending the
|
|
36
|
+
// flag only when given keeps the legacy single-lineage call unchanged.
|
|
37
|
+
if (flags.medium !== undefined)
|
|
38
|
+
body.medium = flags.medium;
|
|
27
39
|
let data;
|
|
28
40
|
try {
|
|
29
|
-
data = (await client.post(`${VERSIONS_PATH}/revert`, { body
|
|
41
|
+
data = (await client.post(`${VERSIONS_PATH}/revert`, { body }));
|
|
30
42
|
}
|
|
31
43
|
catch (err) {
|
|
32
44
|
if (err instanceof FacesAPIError) {
|
|
33
45
|
if (err.statusCode === 404)
|
|
34
46
|
this.error(`No face named '${args.alias}'. It does not exist, or is not yours.`);
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
//
|
|
38
|
-
|
|
47
|
+
// The server's 409s say exactly what is wrong — more than one style to
|
|
48
|
+
// choose between, or nothing left to step back to. Pass them through
|
|
49
|
+
// and add only the command that answers them.
|
|
50
|
+
if (err.statusCode === 409) {
|
|
51
|
+
this.error(`Error (409): ${err.message}\nSee what is there: faces style:versions ${args.alias}`);
|
|
52
|
+
}
|
|
53
|
+
// Reverting a named medium currently fails this way even when the
|
|
54
|
+
// server itself reports can_revert: true (faces-backend-shared#590).
|
|
39
55
|
if (err.statusCode === 500) {
|
|
40
|
-
this.error(`Error (500): ${
|
|
41
|
-
|
|
42
|
-
'
|
|
43
|
-
args.alias);
|
|
56
|
+
this.error(`Error (500): the server failed to revert '${args.alias}'.\n` +
|
|
57
|
+
'Reverting a per-medium style is currently broken upstream and nothing was changed. ' +
|
|
58
|
+
'Tracked as faces-backend-shared#590.');
|
|
44
59
|
}
|
|
45
60
|
this.error(`Error (${err.statusCode}): ${err.message}`);
|
|
46
61
|
}
|
|
@@ -56,9 +71,7 @@ export default class StyleRevert extends BaseCommand {
|
|
|
56
71
|
else {
|
|
57
72
|
this.log(`'${args.alias}' reverted.`);
|
|
58
73
|
}
|
|
59
|
-
|
|
60
|
-
// return. Verified against production.
|
|
61
|
-
this.log(`There is no step forward. To undo this, capture the style again: faces style:make ${args.alias} --all`);
|
|
74
|
+
this.log(`To undo this, capture the style again: faces style:make ${args.alias} --all`);
|
|
62
75
|
this.log(`List versions: faces style:versions ${args.alias}`);
|
|
63
76
|
return data;
|
|
64
77
|
}
|
|
@@ -3,8 +3,10 @@ import { BaseCommand } from '../../base.js';
|
|
|
3
3
|
import { FacesAPIError } from '../../client.js';
|
|
4
4
|
import { EXIT_NO_SUCH_FACE, VERSIONS_PATH, faceExists } from '../../style.js';
|
|
5
5
|
export default class StyleVersions extends BaseCommand {
|
|
6
|
-
static description = 'List the styles captured for a face, and which one it
|
|
7
|
-
'
|
|
6
|
+
static description = 'List the styles captured for a face, and which one it writes in now. A face holds one style per ' +
|
|
7
|
+
'medium, so more than one can be in use at once: an email style and an essay style are separate ' +
|
|
8
|
+
'and do not replace each other. Two versions are kept per medium, which is what style:revert has ' +
|
|
9
|
+
'to go back to.';
|
|
8
10
|
static examples = ['<%= config.bin %> <%= command.id %> alice', '<%= config.bin %> <%= command.id %> alice --json'];
|
|
9
11
|
static flags = { ...BaseCommand.baseFlags };
|
|
10
12
|
static args = {
|
|
@@ -12,6 +14,7 @@ export default class StyleVersions extends BaseCommand {
|
|
|
12
14
|
};
|
|
13
15
|
async run() {
|
|
14
16
|
const { args, flags } = await this.parse(StyleVersions);
|
|
17
|
+
const alias = args.alias;
|
|
15
18
|
const client = this.makeClient(flags);
|
|
16
19
|
let data;
|
|
17
20
|
try {
|
|
@@ -41,14 +44,19 @@ export default class StyleVersions extends BaseCommand {
|
|
|
41
44
|
return data;
|
|
42
45
|
}
|
|
43
46
|
const rows = versions.map((v) => ({
|
|
47
|
+
// A null medium is a map built before media existed, not a missing value.
|
|
48
|
+
medium: v.medium?.trim() || '(before media)',
|
|
44
49
|
version: String(v.version ?? '-'),
|
|
45
50
|
active: v.active ? 'yes' : '-',
|
|
51
|
+
revert: v.can_revert ? 'yes' : '-',
|
|
46
52
|
model: v.model?.trim() || '-',
|
|
47
53
|
created: String(v.created_at ?? '').slice(0, 19).replace('T', ' ') || '-',
|
|
48
54
|
}));
|
|
49
55
|
const cols = [
|
|
56
|
+
['MEDIUM', (r) => r.medium],
|
|
50
57
|
['VERSION', (r) => r.version],
|
|
51
|
-
['
|
|
58
|
+
['IN USE', (r) => r.active],
|
|
59
|
+
['REVERTABLE', (r) => r.revert],
|
|
52
60
|
['MODEL', (r) => r.model],
|
|
53
61
|
['CAPTURED', (r) => r.created],
|
|
54
62
|
];
|
|
@@ -57,20 +65,24 @@ export default class StyleVersions extends BaseCommand {
|
|
|
57
65
|
this.log(line(cols.map(([h]) => h)));
|
|
58
66
|
for (const r of rows)
|
|
59
67
|
this.log(line(cols.map(([, g]) => g(r))));
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
68
|
+
// Several rows are active at once by design: one per medium. Version
|
|
69
|
+
// numbers restart per medium, so a version number alone does not identify
|
|
70
|
+
// a row and the summary names the medium with it.
|
|
63
71
|
const active = versions.filter((v) => v.active);
|
|
72
|
+
const label = (v) => `${v.medium?.trim() || '(before media)'} v${v.version}`;
|
|
64
73
|
this.log('');
|
|
65
|
-
if (active.length ===
|
|
66
|
-
this.log(
|
|
67
|
-
}
|
|
68
|
-
else if (active.length === 0) {
|
|
69
|
-
this.log(`${versions.length} version(s) kept. None is marked active.`);
|
|
74
|
+
if (active.length === 0) {
|
|
75
|
+
this.log(`${versions.length} version(s) kept, none in use.`);
|
|
70
76
|
}
|
|
71
77
|
else {
|
|
72
|
-
this.log(
|
|
73
|
-
|
|
78
|
+
this.log(`In use: ${active.map((v) => label(v)).join(', ')}.`);
|
|
79
|
+
this.log(`${versions.length} version(s) kept in total, two per medium.`);
|
|
80
|
+
}
|
|
81
|
+
const revertable = versions.filter((v) => v.can_revert);
|
|
82
|
+
if (revertable.length > 0) {
|
|
83
|
+
const media = revertable.map((v) => v.medium?.trim()).filter(Boolean);
|
|
84
|
+
this.log(`Can step back: ${revertable.map((v) => label(v)).join(', ')}. ` +
|
|
85
|
+
(media.length > 0 ? `Use: faces style:revert ${alias} --medium ${media[0]} --yes` : ''));
|
|
74
86
|
}
|
|
75
87
|
return data;
|
|
76
88
|
}
|
|
@@ -2,8 +2,10 @@ import { Args, Flags } from '@oclif/core';
|
|
|
2
2
|
import { BaseCommand } from '../../base.js';
|
|
3
3
|
import { FacesAPIError } from '../../client.js';
|
|
4
4
|
import { TeamCatalogService } from '../../team-catalog.js';
|
|
5
|
+
import { resolveFace } from '../../utils.js';
|
|
5
6
|
export default class TeamAdd extends BaseCommand {
|
|
6
|
-
static description = 'Add face(s) to a team'
|
|
7
|
+
static description = 'Add face(s) to a team. Any face this account can reach may join: your own, a published one, or one ' +
|
|
8
|
+
'shared with you. Name someone else\'s as owner:alias, the same address chat uses.';
|
|
7
9
|
static flags = {
|
|
8
10
|
...BaseCommand.baseFlags,
|
|
9
11
|
face: Flags.string({ description: 'Face alias to add (repeatable)', multiple: true, required: true }),
|
|
@@ -11,24 +13,38 @@ export default class TeamAdd extends BaseCommand {
|
|
|
11
13
|
static args = {
|
|
12
14
|
team_id: Args.string({ description: 'Team ID', required: true }),
|
|
13
15
|
};
|
|
16
|
+
static examples = [
|
|
17
|
+
'<%= config.bin %> <%= command.id %> TEAM_ID --face alice --face bob',
|
|
18
|
+
'<%= config.bin %> <%= command.id %> TEAM_ID --face head:socrates',
|
|
19
|
+
];
|
|
14
20
|
async run() {
|
|
15
21
|
const { args, flags } = await this.parse(TeamAdd);
|
|
16
22
|
const client = this.makeClient(flags);
|
|
17
|
-
// Resolve
|
|
23
|
+
// Resolve to iam_ids. The members endpoint takes a uid or an owner:alias
|
|
24
|
+
// and not a bare alias, so resolving here means a user can name any face
|
|
25
|
+
// the way they already name it everywhere else. resolveFace also finds
|
|
26
|
+
// published and shared faces, which the plain face route cannot.
|
|
18
27
|
const iamIds = [];
|
|
19
28
|
for (const alias of flags.face) {
|
|
29
|
+
let resolved;
|
|
20
30
|
try {
|
|
21
|
-
|
|
22
|
-
const id = (face.uid ?? face.id ?? face.iam_id);
|
|
23
|
-
if (!id)
|
|
24
|
-
this.error(`Could not resolve iam_id for face '${alias}'`);
|
|
25
|
-
iamIds.push(id);
|
|
31
|
+
resolved = await resolveFace(client, alias);
|
|
26
32
|
}
|
|
27
33
|
catch (err) {
|
|
28
|
-
if (err instanceof FacesAPIError)
|
|
34
|
+
if (err instanceof FacesAPIError) {
|
|
35
|
+
if (err.statusCode === 404) {
|
|
36
|
+
this.error(`No face '${alias}'. If it belongs to another account, name it as owner:alias. ` +
|
|
37
|
+
'Find it with: faces face:list --public');
|
|
38
|
+
}
|
|
29
39
|
this.error(`Error resolving '${alias}' (${err.statusCode}): ${err.message}`);
|
|
40
|
+
}
|
|
30
41
|
throw err;
|
|
31
42
|
}
|
|
43
|
+
const face = resolved.face;
|
|
44
|
+
const id = (face.uid ?? face.id ?? face.iam_id);
|
|
45
|
+
if (!id)
|
|
46
|
+
this.error(`Could not resolve iam_id for face '${alias}'`);
|
|
47
|
+
iamIds.push(id);
|
|
32
48
|
}
|
|
33
49
|
let data;
|
|
34
50
|
try {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { BaseCommand } from '../../base.js';
|
|
2
2
|
export default class TeamMembers extends BaseCommand {
|
|
3
3
|
static description: string;
|
|
4
|
+
static examples: string[];
|
|
4
5
|
static flags: {
|
|
5
6
|
'base-url': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
6
7
|
token: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
import { Args } from '@oclif/core';
|
|
2
2
|
import { BaseCommand } from '../../base.js';
|
|
3
3
|
import { FacesAPIError } from '../../client.js';
|
|
4
|
+
/**
|
|
5
|
+
* Why a member can no longer be reached, in the terms a user can act on.
|
|
6
|
+
*
|
|
7
|
+
* The two are kept apart deliberately: access can be granted again, a deleted
|
|
8
|
+
* face cannot come back. Rendering them the same would tell someone to go and
|
|
9
|
+
* ask for something that no longer exists.
|
|
10
|
+
*/
|
|
11
|
+
const REASONS = {
|
|
12
|
+
unpublished_or_unshared: 'access was revoked by its owner',
|
|
13
|
+
deleted: 'the face was deleted',
|
|
14
|
+
};
|
|
4
15
|
export default class TeamMembers extends BaseCommand {
|
|
5
|
-
static description = 'List members of a team'
|
|
16
|
+
static description = 'List members of a team. A member you do not own can stop being reachable if its owner revokes ' +
|
|
17
|
+
'access or deletes it; those are listed with the reason rather than dropped.';
|
|
18
|
+
static examples = ['<%= config.bin %> <%= command.id %> TEAM_ID', '<%= config.bin %> <%= command.id %> TEAM_ID --json'];
|
|
6
19
|
static flags = {
|
|
7
20
|
...BaseCommand.baseFlags,
|
|
8
21
|
};
|
|
@@ -17,12 +30,48 @@ export default class TeamMembers extends BaseCommand {
|
|
|
17
30
|
data = await client.get(`/v1/teams/${args.team_id}/members`);
|
|
18
31
|
}
|
|
19
32
|
catch (err) {
|
|
20
|
-
if (err instanceof FacesAPIError)
|
|
33
|
+
if (err instanceof FacesAPIError) {
|
|
34
|
+
if (err.statusCode === 404)
|
|
35
|
+
this.error(`No team '${args.team_id}'. It does not exist, or is not yours.`);
|
|
21
36
|
this.error(`Error (${err.statusCode}): ${err.message}`);
|
|
37
|
+
}
|
|
22
38
|
throw err;
|
|
23
39
|
}
|
|
24
|
-
if (
|
|
25
|
-
|
|
40
|
+
if (this.jsonEnabled())
|
|
41
|
+
return data;
|
|
42
|
+
const members = (data.data ?? data) ?? [];
|
|
43
|
+
if (members.length === 0) {
|
|
44
|
+
this.log('This team has no members.');
|
|
45
|
+
this.log(`Add one with: faces team:add ${args.team_id} --face <alias>`);
|
|
46
|
+
return data;
|
|
47
|
+
}
|
|
48
|
+
const rows = members.map((m) => ({
|
|
49
|
+
alias: m.alias?.trim() || '(unknown)',
|
|
50
|
+
// reachable is absent on older servers; only an explicit false is a problem.
|
|
51
|
+
status: m.reachable === false
|
|
52
|
+
? `unreachable - ${REASONS[m.unreachable_reason ?? ''] ?? m.unreachable_reason ?? 'reason not given'}`
|
|
53
|
+
: 'ok',
|
|
54
|
+
id: m.iam_id ?? '-',
|
|
55
|
+
}));
|
|
56
|
+
const cols = [
|
|
57
|
+
['FACE', (r) => r.alias],
|
|
58
|
+
['STATUS', (r) => r.status],
|
|
59
|
+
['ID', (r) => r.id],
|
|
60
|
+
];
|
|
61
|
+
const w = cols.map(([h, g]) => Math.max(h.length, ...rows.map((r) => g(r).length)));
|
|
62
|
+
const line = (c) => c.map((x, i) => x.padEnd(w[i])).join(' ').trimEnd();
|
|
63
|
+
this.log(line(cols.map(([h]) => h)));
|
|
64
|
+
for (const r of rows)
|
|
65
|
+
this.log(line(cols.map(([, g]) => g(r))));
|
|
66
|
+
const lost = members.filter((m) => m.reachable === false);
|
|
67
|
+
this.log('');
|
|
68
|
+
this.log(`${rows.length} member(s).`);
|
|
69
|
+
if (lost.length > 0) {
|
|
70
|
+
// Say it plainly: a team that silently changed shape is the thing this
|
|
71
|
+
// reporting exists to prevent.
|
|
72
|
+
this.log(`${lost.length} can no longer be reached and will not answer. They are still listed because the ` +
|
|
73
|
+
'team has not changed; remove them with faces team:remove.');
|
|
74
|
+
}
|
|
26
75
|
return data;
|
|
27
76
|
}
|
|
28
77
|
}
|
package/dist/style.d.ts
CHANGED
|
@@ -57,6 +57,12 @@ export interface Selectable {
|
|
|
57
57
|
* which is the one source type a build may not compile for free.
|
|
58
58
|
*/
|
|
59
59
|
isCorpus: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Messages a capture would actually read, by the same rule intake applies.
|
|
62
|
+
* Zero means the capture fails on this source. Undefined on documents and on
|
|
63
|
+
* servers that predate the field.
|
|
64
|
+
*/
|
|
65
|
+
authored?: number;
|
|
60
66
|
/** Present once this source has been printed. */
|
|
61
67
|
printedAt?: string;
|
|
62
68
|
printedMedium?: string;
|
package/dist/style.js
CHANGED
|
@@ -92,6 +92,7 @@ function rowToSelectable(r, sourceType) {
|
|
|
92
92
|
label: r.label?.trim() || '(untitled)',
|
|
93
93
|
synced: Boolean(r.synced),
|
|
94
94
|
isCorpus: sourceType === 'document' || Boolean(r.corpus_kind?.trim()),
|
|
95
|
+
authored: r.authored_message_count ?? undefined,
|
|
95
96
|
// The stamp was renamed alongside the request field, but stored stamps kept
|
|
96
97
|
// the old key (faces-backend-shared#589), so both are read.
|
|
97
98
|
printedMedium: stamp?.medium ?? stamp?.kind,
|
package/dist/team-catalog.d.ts
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
export declare const TEAMS_DIR: string;
|
|
2
|
+
/**
|
|
3
|
+
* Directory name for a team.
|
|
4
|
+
*
|
|
5
|
+
* Local teams are stored under a slug of their display name, so any comparison
|
|
6
|
+
* between the server's teams and the local ones has to normalise the same way
|
|
7
|
+
* in both directions. Exported because doing it in one direction only is how a
|
|
8
|
+
* team whose name contains punctuation ends up looking orphaned every time.
|
|
9
|
+
*/
|
|
10
|
+
export declare function slugifyTeamName(name: string): string;
|
|
2
11
|
export interface TeamFrontmatter {
|
|
3
12
|
id?: string;
|
|
4
13
|
name: string;
|
|
@@ -13,5 +22,13 @@ export declare class TeamCatalogService {
|
|
|
13
22
|
body: string;
|
|
14
23
|
} | null;
|
|
15
24
|
listTeams(): string[];
|
|
25
|
+
/**
|
|
26
|
+
* Whether TEAM.md carries anything beyond the frontmatter the CLI writes.
|
|
27
|
+
*
|
|
28
|
+
* For a team that still exists this body is the server's protocol and gets
|
|
29
|
+
* overwritten on every sync. For one that does not, it is the only copy left,
|
|
30
|
+
* which is the difference between tidying up and losing something.
|
|
31
|
+
*/
|
|
32
|
+
hasLocalContent(name: string): boolean;
|
|
16
33
|
deleteTeam(name: string): void;
|
|
17
34
|
}
|
package/dist/team-catalog.js
CHANGED
|
@@ -2,6 +2,20 @@ import fs from 'node:fs';
|
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
export const TEAMS_DIR = path.join(os.homedir(), '.faces', 'teams');
|
|
5
|
+
/**
|
|
6
|
+
* Directory name for a team.
|
|
7
|
+
*
|
|
8
|
+
* Local teams are stored under a slug of their display name, so any comparison
|
|
9
|
+
* between the server's teams and the local ones has to normalise the same way
|
|
10
|
+
* in both directions. Exported because doing it in one direction only is how a
|
|
11
|
+
* team whose name contains punctuation ends up looking orphaned every time.
|
|
12
|
+
*/
|
|
13
|
+
export function slugifyTeamName(name) {
|
|
14
|
+
return String(name)
|
|
15
|
+
.toLowerCase()
|
|
16
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
17
|
+
.replace(/^-|-$/g, '');
|
|
18
|
+
}
|
|
5
19
|
function quoteYaml(value) {
|
|
6
20
|
if (/[:#\[\]{}&*!|>'"%@`\n]/.test(value) || value !== value.trim() || value === '') {
|
|
7
21
|
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
@@ -91,6 +105,17 @@ export class TeamCatalogService {
|
|
|
91
105
|
.filter(d => d.isDirectory() && fs.existsSync(path.join(TEAMS_DIR, d.name, 'TEAM.md')))
|
|
92
106
|
.map(d => d.name);
|
|
93
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* Whether TEAM.md carries anything beyond the frontmatter the CLI writes.
|
|
110
|
+
*
|
|
111
|
+
* For a team that still exists this body is the server's protocol and gets
|
|
112
|
+
* overwritten on every sync. For one that does not, it is the only copy left,
|
|
113
|
+
* which is the difference between tidying up and losing something.
|
|
114
|
+
*/
|
|
115
|
+
hasLocalContent(name) {
|
|
116
|
+
const team = this.readTeam(name);
|
|
117
|
+
return Boolean(team && team.body.trim());
|
|
118
|
+
}
|
|
94
119
|
deleteTeam(name) {
|
|
95
120
|
try {
|
|
96
121
|
const dir = path.join(TEAMS_DIR, name);
|