faces-cli 1.8.3 → 1.8.5

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/catalog.js CHANGED
@@ -2,14 +2,9 @@ import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { loadConfig } from './config.js';
5
+ import { MAX_FRONTMATTER_VALUE, quoteYaml, unquoteYaml } from './utils.js';
5
6
  export const CATALOG_DIR = path.join(os.homedir(), '.faces', 'catalog');
6
7
  export const CATALOG_INDEX = path.join(os.homedir(), '.faces', 'catalog.json');
7
- function quoteYaml(value) {
8
- if (/[:#\[\]{}&*!|>'"%@`\n]/.test(value) || value !== value.trim() || value === '') {
9
- return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
10
- }
11
- return value;
12
- }
13
8
  function serializeFrontmatter(fm) {
14
9
  const lines = ['---'];
15
10
  lines.push(`name: ${quoteYaml(fm.name)}`);
@@ -57,8 +52,7 @@ function parseFrontmatter(content) {
57
52
  const colonIdx = line.indexOf(':');
58
53
  if (colonIdx > 0) {
59
54
  const k = line.slice(0, colonIdx).trim();
60
- const v = line.slice(colonIdx + 1).trim().replace(/^"(.*)"$/, '$1');
61
- attributes[k] = v;
55
+ attributes[k] = unquoteYaml(line.slice(colonIdx + 1));
62
56
  }
63
57
  continue;
64
58
  }
@@ -67,7 +61,7 @@ function parseFrontmatter(content) {
67
61
  if (colonIdx < 0)
68
62
  continue;
69
63
  const key = line.slice(0, colonIdx).trim();
70
- const val = line.slice(colonIdx + 1).trim().replace(/^"(.*)"$/, '$1');
64
+ const val = unquoteYaml(line.slice(colonIdx + 1));
71
65
  if (key === 'name')
72
66
  fm.name = val;
73
67
  else if (key === 'description')
@@ -137,7 +131,16 @@ export class CatalogService {
137
131
  fm.description = faceData.description;
138
132
  }
139
133
  else if (existingDescription) {
140
- fm.description = existingDescription;
134
+ // A description this size is corrupt, not long. Carrying it forward is
135
+ // what kept it alive across writes; dropping it lets the next sync
136
+ // replace it with the real one.
137
+ if (existingDescription.length > MAX_FRONTMATTER_VALUE) {
138
+ process.stderr.write(`warn: discarded a ${existingDescription.length}-character description on '${alias}' — ` +
139
+ 'too large to be real. It will be restored from the server on the next sync.\n');
140
+ }
141
+ else {
142
+ fm.description = existingDescription;
143
+ }
141
144
  }
142
145
  if (faceData.default_model)
143
146
  fm.default_model = faceData.default_model;
@@ -4,6 +4,7 @@ import { Flags } from '@oclif/core';
4
4
  import { BaseCommand } from '../../base.js';
5
5
  import { FacesAPIError } from '../../client.js';
6
6
  import { loadConfig } from '../../config.js';
7
+ import { unquoteYaml } from '../../utils.js';
7
8
  import { CatalogService, CATALOG_DIR } from '../../catalog.js';
8
9
  import { TeamCatalogService, TEAMS_DIR, slugifyTeamName } from '../../team-catalog.js';
9
10
  import { resolveEndpoint, MESSAGES_ENDPOINT, RESPONSES_ENDPOINT } from '../../routing.js';
@@ -67,7 +68,7 @@ export default class CatalogDoctor extends BaseCommand {
67
68
  if (ci < 0)
68
69
  continue;
69
70
  const k = line.slice(0, ci).trim();
70
- const v = line.slice(ci + 1).trim().replace(/^"(.*)"$/, '$1');
71
+ const v = unquoteYaml(line.slice(ci + 1));
71
72
  if (k === 'name')
72
73
  fm.name = v;
73
74
  if (k === 'description')
@@ -3,9 +3,10 @@ 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. 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.';
6
+ static description = 'Go back to the style a face had before the last style:make. Two versions are kept per medium, so ' +
7
+ 'this is a toggle: running it again returns to where you started. A face holds one style per medium, ' +
8
+ 'so name which one with --medium when it has more than one. It changes how the face writes ' +
9
+ 'immediately, so it asks first.';
9
10
  static examples = [
10
11
  '<%= config.bin %> <%= command.id %> alice --yes',
11
12
  '<%= config.bin %> <%= command.id %> alice --medium email --yes',
@@ -50,13 +51,6 @@ export default class StyleRevert extends BaseCommand {
50
51
  if (err.statusCode === 409) {
51
52
  this.error(`Error (409): ${err.message}\nSee what is there: faces style:versions ${args.alias}`);
52
53
  }
53
- // Reverting a named medium currently fails this way even when the
54
- // server itself reports can_revert: true (faces-backend-shared#590).
55
- if (err.statusCode === 500) {
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.');
59
- }
60
54
  this.error(`Error (${err.statusCode}): ${err.message}`);
61
55
  }
62
56
  throw err;
@@ -71,8 +65,11 @@ export default class StyleRevert extends BaseCommand {
71
65
  else {
72
66
  this.log(`'${args.alias}' reverted.`);
73
67
  }
74
- this.log(`To undo this, capture the style again: faces style:make ${args.alias} --all`);
75
- this.log(`List versions: faces style:versions ${args.alias}`);
68
+ // Reverting again returns to where you started it is a toggle between the
69
+ // two kept versions, not a walk backwards. Name the version it goes back to
70
+ // so the user does not have to hold it in their head.
71
+ const back = from === undefined ? '' : ` to return to version ${from}`;
72
+ this.log(`Run it again${back}. List versions: faces style:versions ${args.alias}`);
76
73
  return data;
77
74
  }
78
75
  }
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
+ import { quoteYaml, unquoteYaml } from './utils.js';
4
5
  export const TEAMS_DIR = path.join(os.homedir(), '.faces', 'teams');
5
6
  /**
6
7
  * Directory name for a team.
@@ -16,12 +17,6 @@ export function slugifyTeamName(name) {
16
17
  .replace(/[^a-z0-9]+/g, '-')
17
18
  .replace(/^-|-$/g, '');
18
19
  }
19
- function quoteYaml(value) {
20
- if (/[:#\[\]{}&*!|>'"%@`\n]/.test(value) || value !== value.trim() || value === '') {
21
- return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
22
- }
23
- return value;
24
- }
25
20
  function serializeFrontmatter(fm) {
26
21
  const lines = ['---'];
27
22
  if (fm.id)
@@ -52,13 +47,13 @@ function parseFrontmatter(content) {
52
47
  const key = line.slice(0, colonIdx).trim();
53
48
  const val = line.slice(colonIdx + 1).trim();
54
49
  if (key === 'id') {
55
- fm.id = val.replace(/^"(.*)"$/, '$1');
50
+ fm.id = unquoteYaml(val);
56
51
  }
57
52
  else if (key === 'name') {
58
- fm.name = val.replace(/^"(.*)"$/, '$1');
53
+ fm.name = unquoteYaml(val);
59
54
  }
60
55
  else if (key === 'description') {
61
- fm.description = val.replace(/^"(.*)"$/, '$1');
56
+ fm.description = unquoteYaml(val);
62
57
  }
63
58
  else if (key === 'tags') {
64
59
  const inner = val.replace(/^\[/, '').replace(/\]$/, '');
package/dist/utils.d.ts CHANGED
@@ -87,3 +87,29 @@ export interface ResolvedFace {
87
87
  export declare function resolveFace(client: {
88
88
  get: (path: string) => Promise<unknown>;
89
89
  }, spec: string, include?: string): Promise<ResolvedFace>;
90
+ /**
91
+ * A description this large is not a description.
92
+ *
93
+ * The escaping bug this bound exists to catch grew one field to 128 MiB by
94
+ * doubling it on every unrelated write. A ceiling turns a silent geometric
95
+ * corruption into one complaint on the first doubling, and stops a value that
96
+ * is already corrupt from being carried forward forever.
97
+ */
98
+ export declare const MAX_FRONTMATTER_VALUE: number;
99
+ /**
100
+ * Quote a YAML scalar, escaping what would otherwise break the line.
101
+ *
102
+ * Paired with {@link unquoteYaml}. The two must stay exact inverses: if one
103
+ * escapes and the other does not unescape, every read-modify-write doubles the
104
+ * backslashes in the value, which is invisible until a catalog is measured in
105
+ * hundreds of megabytes.
106
+ */
107
+ export declare function quoteYaml(value: string): string;
108
+ /**
109
+ * Reverse {@link quoteYaml}: strip the surrounding quotes, then unescape.
110
+ *
111
+ * The unescape is a single left-to-right pass, not two sequential replaces.
112
+ * `\\` and `\"` have to be consumed in the order they appear, or a value
113
+ * containing a literal backslash before a quote comes back wrong.
114
+ */
115
+ export declare function unquoteYaml(raw: string): string;
package/dist/utils.js CHANGED
@@ -194,3 +194,40 @@ async function findForeignFace(client, alias, owner, q) {
194
194
  owner: ownedBy,
195
195
  };
196
196
  }
197
+ /**
198
+ * A description this large is not a description.
199
+ *
200
+ * The escaping bug this bound exists to catch grew one field to 128 MiB by
201
+ * doubling it on every unrelated write. A ceiling turns a silent geometric
202
+ * corruption into one complaint on the first doubling, and stops a value that
203
+ * is already corrupt from being carried forward forever.
204
+ */
205
+ export const MAX_FRONTMATTER_VALUE = 64 * 1024;
206
+ /**
207
+ * Quote a YAML scalar, escaping what would otherwise break the line.
208
+ *
209
+ * Paired with {@link unquoteYaml}. The two must stay exact inverses: if one
210
+ * escapes and the other does not unescape, every read-modify-write doubles the
211
+ * backslashes in the value, which is invisible until a catalog is measured in
212
+ * hundreds of megabytes.
213
+ */
214
+ export function quoteYaml(value) {
215
+ if (/[:#[\]{}&*!|>'"%@`\n]/.test(value) || value !== value.trim() || value === '') {
216
+ return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
217
+ }
218
+ return value;
219
+ }
220
+ /**
221
+ * Reverse {@link quoteYaml}: strip the surrounding quotes, then unescape.
222
+ *
223
+ * The unescape is a single left-to-right pass, not two sequential replaces.
224
+ * `\\` and `\"` have to be consumed in the order they appear, or a value
225
+ * containing a literal backslash before a quote comes back wrong.
226
+ */
227
+ export function unquoteYaml(raw) {
228
+ const trimmed = raw.trim();
229
+ const m = /^"([\S\s]*)"$/.exec(trimmed);
230
+ if (!m)
231
+ return trimmed;
232
+ return m[1].replace(/\\(["\\])/g, '$1');
233
+ }