faces-cli 1.8.0 → 1.8.2

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.
@@ -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
- remoteTeams = (resp.data ?? resp)
113
- .filter(t => t.kind !== 'personal');
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
- const slug = String(t.name).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
118
- return !localTeamNames.has(slug);
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 { missing: missing.length, stale: stale.length, orphaned: orphaned.length, noDescription: noDescription.length, missingTeams: missingTeams.length };
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).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
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
- this.log(`Fixed ${fixed} face(s), removed ${removed} orphan(s), synced ${teamsSynced} team(s).`);
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 { fixed, removed, teamsSynced, generated: flags.generate ? noDescription.length : 0 };
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
- const path = `/v1/faces/${args.face_id}?include=${[...includes].join(',')}`;
24
- let data;
24
+ let resolved;
25
25
  try {
26
- data = await client.get(path);
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');
@@ -3,7 +3,6 @@ export default class StyleDelete extends BaseCommand {
3
3
  static description: string;
4
4
  static examples: string[];
5
5
  static flags: {
6
- scope: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
7
6
  yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
8
7
  'base-url': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
8
  token: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
@@ -3,26 +3,28 @@ import { createInterface } from 'node:readline';
3
3
  import { BaseCommand } from '../../base.js';
4
4
  import { FacesAPIError } from '../../client.js';
5
5
  import { STYLE_FACES_PATH } from '../../style.js';
6
- /** What each scope destroys, in the words the user needs before choosing. */
7
- const SCOPES = {
8
- map: 'the captured style and everything derived from it. Your uploaded material is kept, so it can be captured again without re-uploading.',
9
- all: 'the captured style AND the material it was built from. The uploaded corpus is deleted. This cannot be undone and nothing can rebuild it.',
10
- };
6
+ /**
7
+ * The API takes a scope: `map` clears the captured style, `all` clears it and
8
+ * destroys the uploaded material with it. This CLI only ever sends `map`.
9
+ *
10
+ * Nothing that removes a style should be able to take source text with it. A
11
+ * user reaching for "delete the style" is not asking to lose the writing it was
12
+ * learned from, and the two are one keystroke apart on the same command. Source
13
+ * text is deleted through the commands that own it — compile:doc:delete and
14
+ * compile:thread:delete — where that is the whole point of the call rather than
15
+ * a side effect of a flag value.
16
+ */
17
+ const SCOPE = 'map';
11
18
  export default class StyleDelete extends BaseCommand {
12
- static description = 'Delete a face\'s captured style. --scope map forgets the style and keeps the material it came from. ' +
13
- '--scope all deletes the uploaded material too, permanently. There is no default because the two ' +
14
- 'differ by whether your material survives.';
19
+ static description = "Delete a face's captured style. The material it was learned from is kept, so the style can be " +
20
+ 'captured again without re-uploading. This never deletes documents or threads: remove those with ' +
21
+ 'compile:doc:delete or compile:thread:delete.';
15
22
  static examples = [
16
- '<%= config.bin %> <%= command.id %> alice --scope map',
17
- '<%= config.bin %> <%= command.id %> alice --scope all --yes',
23
+ '<%= config.bin %> <%= command.id %> alice',
24
+ '<%= config.bin %> <%= command.id %> alice --yes',
18
25
  ];
19
26
  static flags = {
20
27
  ...BaseCommand.baseFlags,
21
- scope: Flags.string({
22
- description: 'map: forget the style, keep the material. all: delete the material with it, permanently.',
23
- options: ['map', 'all'],
24
- required: true,
25
- }),
26
28
  yes: Flags.boolean({ description: 'Skip confirmation', default: false }),
27
29
  };
28
30
  static args = {
@@ -31,32 +33,32 @@ export default class StyleDelete extends BaseCommand {
31
33
  async run() {
32
34
  const { args, flags } = await this.parse(StyleDelete);
33
35
  const client = this.makeClient(flags);
34
- const scope = flags.scope;
35
36
  if (!flags.yes) {
36
- const what = `This deletes ${SCOPES[scope]}`;
37
+ const what = `'${args.alias}' will forget its captured style. Its documents, threads and uploaded material are kept.`;
37
38
  if (this.jsonEnabled())
38
39
  this.error(`${what}\nRe-run with --yes to confirm.`);
39
- const ok = await this.confirm(`${what}\nDelete for '${args.alias}'?`);
40
+ const ok = await this.confirm(`${what}\nDelete the style?`);
40
41
  if (!ok)
41
42
  this.error('Aborted. Nothing was deleted.');
42
43
  }
43
44
  let data;
44
45
  try {
45
- data = await client.delete(`${STYLE_FACES_PATH}/${encodeURIComponent(args.alias)}?scope=${scope}`);
46
+ data = await client.delete(`${STYLE_FACES_PATH}/${encodeURIComponent(args.alias)}?scope=${SCOPE}`);
46
47
  }
47
48
  catch (err) {
48
49
  if (err instanceof FacesAPIError) {
49
50
  if (err.statusCode === 404)
50
51
  this.error(`No face named '${args.alias}'. It does not exist, or is not yours.`);
52
+ if (err.statusCode === 409)
53
+ this.error(`Error (409): ${err.message}\nThe face may be locked. Unlock it first.`);
51
54
  this.error(`Error (${err.statusCode}): ${err.message}`);
52
55
  }
53
56
  throw err;
54
57
  }
55
58
  if (this.jsonEnabled())
56
59
  return data;
57
- this.log(scope === 'all'
58
- ? `Deleted the style and the uploaded material for '${args.alias}'.`
59
- : `Deleted the style for '${args.alias}'. The material it came from is still there, so you can capture it again: faces style:make ${args.alias} --all`);
60
+ this.log(`'${args.alias}' has forgotten its style. Its source material is untouched.`);
61
+ this.log(`Capture it again with: faces style:make ${args.alias} --all`);
60
62
  return data;
61
63
  }
62
64
  confirm(message) {
@@ -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 any selected source that does not declare one. Each medium is analysed on its own, ' +
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
- const medium = declared ?? flags.medium ?? s.medium;
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. It steps backwards through the kept ' +
7
- 'versions and there is no step forward, so the way to undo a revert is to capture the style again. ' +
8
- 'It changes how the face writes immediately, so it asks first.';
9
- static examples = ['<%= config.bin %> <%= command.id %> alice --yes'];
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, straight away. Re-run with --yes to confirm.\n` +
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: { self_face: args.alias } }));
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
- if (err.statusCode === 409)
36
- this.error(`Error (409): ${err.message}\nThe face may be locked. Unlock it first.`);
37
- // Reverting past the oldest kept version fails this way rather than
38
- // with a clean refusal (faces-backend-shared#590).
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): ${err.message}\n` +
41
- `'${args.alias}' may already be on the oldest style that is still kept, in which case there ` +
42
- 'is nothing further back to go to. Check with: faces style:versions ' +
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
- // Not "run it again to undo": another revert steps back again, it does not
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 is writing in now. Each style:make keeps ' +
7
- 'the previous one, so this is what style:revert has to go back to.';
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
- ['ACTIVE', (r) => r.active],
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
- // More than one row can come back marked active (faces-backend-shared#589).
61
- // Saying "you are writing in version N" would be a guess, so when the data
62
- // is ambiguous the command says so instead of picking one.
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 === 1) {
66
- this.log(`Writing in version ${active[0].version} now. ${versions.length} version(s) kept.`);
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(`${versions.length} version(s) kept, and ${active.length} are marked active, so which one is in ` +
73
- 'use cannot be read from this list. Reported upstream.');
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. A team may only contain faces this account owns: a published or shared face ' +
8
+ 'can be chatted with but cannot be a member.';
7
9
  static flags = {
8
10
  ...BaseCommand.baseFlags,
9
11
  face: Flags.string({ description: 'Face alias to add (repeatable)', multiple: true, required: true }),
@@ -14,21 +16,40 @@ export default class TeamAdd extends BaseCommand {
14
16
  async run() {
15
17
  const { args, flags } = await this.parse(TeamAdd);
16
18
  const client = this.makeClient(flags);
17
- // Resolve aliases to iam_ids
19
+ // Resolve aliases to iam_ids. resolveFace also finds published and shared
20
+ // faces, which the plain face route cannot: without that a reachable face
21
+ // fails here as 404 or 403 depending on how it was named, and both read as
22
+ // a typo rather than as the rule that teams are owner-scoped.
18
23
  const iamIds = [];
19
24
  for (const alias of flags.face) {
25
+ let resolved;
20
26
  try {
21
- const face = await client.get(`/v1/faces/${alias}`);
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);
27
+ resolved = await resolveFace(client, alias);
26
28
  }
27
29
  catch (err) {
28
- if (err instanceof FacesAPIError)
30
+ if (err instanceof FacesAPIError) {
31
+ if (err.statusCode === 404) {
32
+ this.error(`No face '${alias}'. If it belongs to another account, name it as owner:alias. ` +
33
+ 'Find it with: faces face:list --public');
34
+ }
29
35
  this.error(`Error resolving '${alias}' (${err.statusCode}): ${err.message}`);
36
+ }
30
37
  throw err;
31
38
  }
39
+ // The server enforces this too, but only once it has an id, which this
40
+ // command cannot reach for a face it does not own. Say the real reason.
41
+ if (!resolved.owned) {
42
+ this.error(`A team may only contain faces you own, and '${resolved.handle}' is owned by ` +
43
+ `'${resolved.owner ?? 'another account'}'. It is published or shared with you, so you can chat ` +
44
+ `it as ${resolved.handle}@<model>, but it cannot be a team member.\n` +
45
+ 'Do not create a copy to work around this: the copy would be a different face with different ' +
46
+ 'compiled material.');
47
+ }
48
+ const face = resolved.face;
49
+ const id = (face.uid ?? face.id ?? face.iam_id);
50
+ if (!id)
51
+ this.error(`Could not resolve iam_id for face '${alias}'`);
52
+ iamIds.push(id);
32
53
  }
33
54
  let data;
34
55
  try {
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,
@@ -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
  }
@@ -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);