faces-cli 1.7.8 → 1.7.9

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.
@@ -1,10 +1,11 @@
1
1
  import { BaseCommand } from '../../../base.js';
2
2
  export default class CompileDoc extends BaseCommand {
3
3
  static description: string;
4
+ static examples: string[];
4
5
  static flags: {
5
6
  label: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
6
7
  content: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
7
- file: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
8
+ file: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
8
9
  perspective: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
9
10
  timeout: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
10
11
  'no-wait': import("@oclif/core/interfaces").BooleanFlag<boolean>;
@@ -16,4 +17,10 @@ export default class CompileDoc extends BaseCommand {
16
17
  face_id: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
17
18
  };
18
19
  run(): Promise<unknown>;
20
+ /**
21
+ * `documents` is always present and always in input order. For a single
22
+ * source the compiled/started body is also spread at the top level, so
23
+ * callers that already read `.document_id` keep working.
24
+ */
25
+ private shape;
19
26
  }
@@ -1,21 +1,28 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
2
  import fs from 'node:fs';
3
+ import path from 'node:path';
3
4
  import { BaseCommand } from '../../../base.js';
4
5
  import { FacesAPIError } from '../../../client.js';
5
6
  import { pollCompileProgress } from '../../../poll.js';
6
7
  export default class CompileDoc extends BaseCommand {
7
- static description = 'Compile a document into a face (create → make in one step)';
8
+ static description = 'Compile one or more documents into a face (create → make in one step). ' +
9
+ 'Repeat --file to submit several documents in a single command; each file becomes its own ' +
10
+ 'document rather than being concatenated.';
11
+ static examples = [
12
+ '<%= config.bin %> <%= command.id %> alice --file notes.txt',
13
+ '<%= config.bin %> <%= command.id %> alice --file a.txt --file b.txt --file c.txt --no-wait --json',
14
+ ];
8
15
  static flags = {
9
16
  ...BaseCommand.baseFlags,
10
- label: Flags.string({ description: 'Document label/title' }),
11
- content: Flags.string({ description: 'Inline text content' }),
12
- file: Flags.string({ description: 'Read content from file' }),
17
+ label: Flags.string({ description: 'Document label/title (single document only; with several files each is labelled by its filename)' }),
18
+ content: Flags.string({ description: 'Inline text content', exclusive: ['file'] }),
19
+ file: Flags.string({ description: 'Read content from file (repeatable — each file becomes its own document)', multiple: true }),
13
20
  perspective: Flags.string({
14
21
  description: 'Perspective',
15
22
  options: ['first-person', 'third-person'],
16
23
  default: 'first-person',
17
24
  }),
18
- timeout: Flags.integer({ description: 'Compile timeout in seconds (default: 600)', default: 600 }),
25
+ timeout: Flags.integer({ description: 'Compile timeout in seconds, per document (default: 600)', default: 600 }),
19
26
  'no-wait': Flags.boolean({
20
27
  description: 'Create and trigger compilation, then return immediately without polling.',
21
28
  default: false,
@@ -28,79 +35,181 @@ export default class CompileDoc extends BaseCommand {
28
35
  const { args, flags } = await this.parse(CompileDoc);
29
36
  const client = this.makeClient(flags);
30
37
  const json = this.jsonEnabled();
31
- // Resolve content
32
- let content = flags.content;
33
- if (flags.file) {
34
- if (!fs.existsSync(flags.file))
35
- this.error(`File not found: ${flags.file}`);
36
- content = fs.readFileSync(flags.file, 'utf8');
37
- }
38
- if (!content)
38
+ const files = flags.file ?? [];
39
+ if (files.length === 0 && !flags.content)
39
40
  this.error('Provide --content or --file.');
40
- // Step 1: Create document
41
- if (!json)
42
- process.stderr.write('Creating document... ');
43
- let doc;
44
- try {
45
- const payload = { alias: args.face_id, content };
46
- if (flags.label)
47
- payload.label = flags.label;
48
- if (flags.perspective)
49
- payload.perspective = flags.perspective;
50
- doc = await client.post('/v1/compile/documents', { body: payload });
51
- }
52
- catch (err) {
53
- if (err instanceof FacesAPIError)
54
- this.error(`Error (${err.statusCode}): ${err.message}`);
55
- throw err;
41
+ if (flags.label && files.length > 1) {
42
+ this.error('--label applies to a single document. With several --file flags each document is labelled by its filename.');
56
43
  }
57
- const docId = (doc.document_id ?? doc.id);
58
- if (!json)
59
- process.stderr.write(`done (${docId})\n`);
60
- // Step 2: Make (prepare + sync in one call, returns 202)
61
- let makeResponse;
62
- try {
63
- makeResponse = await client.post(`/v1/compile/documents/${docId}/make`);
44
+ // Read every source up front, keeping failures in place rather than
45
+ // collecting them separately — results must come back in input order so a
46
+ // caller can line them up against what it passed. A file that cannot be
47
+ // used is a per-file failure, not a reason to abandon the files that are
48
+ // fine: a whole-call failure leaves the caller unable to tell which file
49
+ // was at fault.
50
+ const sources = files.map((file) => {
51
+ try {
52
+ return { name: file, content: readTextFile(file) };
53
+ }
54
+ catch (err) {
55
+ return { name: file, readError: err instanceof Error ? err.message : String(err) };
56
+ }
57
+ });
58
+ if (flags.content)
59
+ sources.push({ name: '(--content)', content: flags.content });
60
+ const results = [];
61
+ const multi = files.length > 1;
62
+ const started = [];
63
+ for (const source of sources) {
64
+ if (source.readError !== undefined) {
65
+ results.push({ file: source.name, error: source.readError });
66
+ continue;
67
+ }
68
+ try {
69
+ const payload = { alias: args.face_id, content: source.content };
70
+ if (flags.label)
71
+ payload.label = flags.label;
72
+ else if (multi)
73
+ payload.label = path.basename(source.name);
74
+ if (flags.perspective)
75
+ payload.perspective = flags.perspective;
76
+ if (!json)
77
+ process.stderr.write(`Creating document${multi ? ` (${source.name})` : ''}... `);
78
+ const doc = (await client.post('/v1/compile/documents', { body: payload }));
79
+ const docId = String(doc.document_id ?? doc.id ?? '');
80
+ if (!docId)
81
+ throw new Error('Server did not return a document_id');
82
+ if (!json)
83
+ process.stderr.write(`done (${docId})\n`);
84
+ const makeResponse = (await client.post(`/v1/compile/documents/${docId}/make`));
85
+ const chunksTotal = makeResponse.chunks_total;
86
+ started.push({ source, docId, chunksTotal });
87
+ results.push({ file: source.name, document_id: docId, chunks_total: chunksTotal });
88
+ }
89
+ catch (err) {
90
+ if (!json)
91
+ process.stderr.write('failed\n');
92
+ const message = err instanceof FacesAPIError ? `Error (${err.statusCode}): ${err.message}` : err instanceof Error ? err.message : String(err);
93
+ results.push({ file: source.name, error: message });
94
+ }
64
95
  }
65
- catch (err) {
66
- if (err instanceof FacesAPIError)
67
- this.error(`Error (${err.statusCode}): ${err.message}`);
68
- throw err;
96
+ if (started.length === 0) {
97
+ // Nothing was accepted — report every reason, then fail.
98
+ for (const r of results)
99
+ if (r.error)
100
+ process.stderr.write(`${r.file}: ${r.error}\n`);
101
+ this.error(results.length === 1 ? 'Document was not accepted.' : 'No documents were accepted.');
69
102
  }
70
- const chunksTotal = makeResponse.chunks_total;
103
+ if (!json)
104
+ for (const r of results)
105
+ if (r.error)
106
+ process.stderr.write(`${r.file}: ${r.error}\n`);
71
107
  if (flags['no-wait']) {
72
108
  if (!json) {
73
- process.stderr.write(`Compilation started in background${chunksTotal ? ` (${chunksTotal} chunks)` : ''}.\n`);
74
- process.stderr.write(`Poll with: faces compile:doc:get ${docId} --json\n`);
109
+ for (const s of started) {
110
+ process.stderr.write(`Compilation started in background${s.chunksTotal ? ` (${s.chunksTotal} chunks)` : ''}: ${s.docId}\n`);
111
+ }
112
+ process.stderr.write(`Poll with: faces compile:doc:get <id> --json\n`);
75
113
  }
76
- return makeResponse;
114
+ return this.shape(results);
77
115
  }
78
- if (!json)
79
- process.stderr.write(`Compiling${chunksTotal ? ` (${chunksTotal} chunks)` : ''}:\n`);
80
- // Step 3: Poll until synced
81
- let result;
82
- try {
83
- result = await pollCompileProgress(client, docId, {
84
- timeoutMs: flags.timeout * 1000,
85
- onProgress: (p) => {
86
- if (!json) {
116
+ for (const s of started) {
117
+ if (!json)
118
+ process.stderr.write(`Compiling ${multi ? `${s.source.name} ` : ''}${s.chunksTotal ? `(${s.chunksTotal} chunks)` : ''}:\n`);
119
+ try {
120
+ const result = (await pollCompileProgress(client, s.docId, {
121
+ timeoutMs: flags.timeout * 1000,
122
+ onProgress: (p) => {
123
+ if (json)
124
+ return;
87
125
  const c = p.current_counts;
88
126
  const counts = c ? `ε=${c.epsilon} β=${c.beta} δ=${c.delta} α=${c.alpha}` : '';
89
127
  const phase = p.prepare_status === 'syncing' ? ' (syncing)' : '';
90
128
  process.stderr.write(` [${p.chunks_completed ?? '?'}/${p.chunks_total ?? '?'}] ${counts}${phase}\n`);
91
- }
92
- },
93
- });
94
- }
95
- catch (err) {
96
- if (err instanceof Error)
97
- this.error(err.message);
98
- throw err;
129
+ },
130
+ }));
131
+ const hit = results.find((r) => r.document_id === s.docId);
132
+ if (hit)
133
+ hit.compiled = result;
134
+ }
135
+ catch (err) {
136
+ const hit = results.find((r) => r.document_id === s.docId);
137
+ if (hit)
138
+ hit.error = err instanceof Error ? err.message : String(err);
139
+ if (!json)
140
+ process.stderr.write(`${s.source.name}: ${err instanceof Error ? err.message : String(err)}\n`);
141
+ }
99
142
  }
100
143
  if (!json)
101
144
  process.stderr.write('Done.\n');
102
- if (json)
103
- this.log(JSON.stringify(result, null, 2));
104
- return result;
145
+ return this.shape(results);
146
+ }
147
+ /**
148
+ * `documents` is always present and always in input order. For a single
149
+ * source the compiled/started body is also spread at the top level, so
150
+ * callers that already read `.document_id` keep working.
151
+ */
152
+ shape(results) {
153
+ const documents = results.map((r) => {
154
+ const out = { file: r.file };
155
+ if (r.document_id)
156
+ out.document_id = r.document_id;
157
+ if (r.chunks_total !== undefined)
158
+ out.chunks_total = r.chunks_total;
159
+ if (r.error)
160
+ out.error = r.error;
161
+ return out;
162
+ });
163
+ const body = { documents };
164
+ if (results.length === 1) {
165
+ const only = results[0];
166
+ Object.assign(body, only.compiled ?? {}, only.document_id ? { document_id: only.document_id } : {});
167
+ if (only.chunks_total !== undefined && body.chunks_total === undefined)
168
+ body.chunks_total = only.chunks_total;
169
+ body.documents = documents;
170
+ }
171
+ return body;
105
172
  }
106
173
  }
174
+ /** Extensions the compile-documents endpoint takes as inline text. */
175
+ const BINARY_HINTS = {
176
+ '.pdf': 'PDF is supported, but not by this command — use: faces compile:upload <alias> --file <path> --kind document',
177
+ '.docx': 'Word documents are not supported. Convert it to text first (e.g. `textutil -convert txt file.docx` on macOS).',
178
+ '.doc': 'Word documents are not supported. Convert it to text first.',
179
+ '.pages': 'Pages documents are not supported. Export it as text or PDF first.',
180
+ '.rtf': 'RTF is not supported. Convert it to plain text first (e.g. `textutil -convert txt file.rtf`).',
181
+ '.key': 'Keynote files are not supported. Export the text first.',
182
+ '.pptx': 'PowerPoint files are not supported. Export the text first.',
183
+ '.xlsx': 'Excel files are not supported. Export as .csv first.',
184
+ '.zip': 'Archives are not supported. Extract it and pass the text files.',
185
+ '.epub': 'EPUB is not supported. Convert it to text first.',
186
+ };
187
+ /**
188
+ * Read a file as text, refusing binary before it reaches the API.
189
+ *
190
+ * `compile:doc` posts its content as a text field, so a binary file used to
191
+ * travel all the way to the server and come back as a 500 ("A string literal
192
+ * cannot contain NUL (0x00) characters") that named neither the file nor the
193
+ * cause. The NUL scan is the general check — the extension table only makes the
194
+ * message more useful for formats people actually try.
195
+ */
196
+ function readTextFile(file) {
197
+ if (!fs.existsSync(file))
198
+ throw new Error(`File not found: ${file}`);
199
+ const stat = fs.statSync(file);
200
+ if (stat.isDirectory())
201
+ throw new Error(`Not a file: ${file}`);
202
+ // Extension first, and independent of the byte scan: a small PDF can contain
203
+ // no NUL at all and would otherwise be posted as mojibake. A known format
204
+ // always gets the answer for that format.
205
+ const hint = BINARY_HINTS[path.extname(file).toLowerCase()];
206
+ if (hint)
207
+ throw new Error(hint);
208
+ const buf = fs.readFileSync(file);
209
+ if (buf.includes(0))
210
+ throw new Error('Looks like a binary file, not text. Convert it to text first.');
211
+ const content = buf.toString('utf8');
212
+ if (content.trim() === '')
213
+ throw new Error('File is empty.');
214
+ return content;
215
+ }