faces-cli 1.7.16 → 1.8.1
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/style/delete.d.ts +16 -0
- package/dist/commands/style/delete.js +73 -0
- package/dist/commands/style/make.d.ts +45 -0
- package/dist/commands/style/make.js +271 -0
- package/dist/commands/style/revert.d.ts +15 -0
- package/dist/commands/style/revert.js +65 -0
- package/dist/commands/style/status.d.ts +19 -0
- package/dist/commands/style/status.js +102 -0
- package/dist/commands/style/upload.d.ts +18 -0
- package/dist/commands/style/upload.js +81 -0
- package/dist/commands/style/versions.d.ts +14 -0
- package/dist/commands/style/versions.js +77 -0
- package/dist/routing.d.ts +9 -0
- package/dist/routing.js +20 -0
- package/dist/style.d.ts +108 -0
- package/dist/style.js +258 -0
- package/oclif.manifest.json +1966 -1453
- package/package.json +4 -1
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { BaseCommand } from '../../base.js';
|
|
2
|
+
export default class StyleDelete extends BaseCommand {
|
|
3
|
+
static description: string;
|
|
4
|
+
static examples: string[];
|
|
5
|
+
static flags: {
|
|
6
|
+
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
7
|
+
'base-url': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
8
|
+
token: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
9
|
+
'api-key': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
|
+
};
|
|
11
|
+
static args: {
|
|
12
|
+
alias: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
|
|
13
|
+
};
|
|
14
|
+
run(): Promise<unknown>;
|
|
15
|
+
private confirm;
|
|
16
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { Args, Flags } from '@oclif/core';
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
import { BaseCommand } from '../../base.js';
|
|
4
|
+
import { FacesAPIError } from '../../client.js';
|
|
5
|
+
import { STYLE_FACES_PATH } from '../../style.js';
|
|
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';
|
|
18
|
+
export default class StyleDelete extends BaseCommand {
|
|
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.';
|
|
22
|
+
static examples = [
|
|
23
|
+
'<%= config.bin %> <%= command.id %> alice',
|
|
24
|
+
'<%= config.bin %> <%= command.id %> alice --yes',
|
|
25
|
+
];
|
|
26
|
+
static flags = {
|
|
27
|
+
...BaseCommand.baseFlags,
|
|
28
|
+
yes: Flags.boolean({ description: 'Skip confirmation', default: false }),
|
|
29
|
+
};
|
|
30
|
+
static args = {
|
|
31
|
+
alias: Args.string({ description: 'Face alias', required: true }),
|
|
32
|
+
};
|
|
33
|
+
async run() {
|
|
34
|
+
const { args, flags } = await this.parse(StyleDelete);
|
|
35
|
+
const client = this.makeClient(flags);
|
|
36
|
+
if (!flags.yes) {
|
|
37
|
+
const what = `'${args.alias}' will forget its captured style. Its documents, threads and uploaded material are kept.`;
|
|
38
|
+
if (this.jsonEnabled())
|
|
39
|
+
this.error(`${what}\nRe-run with --yes to confirm.`);
|
|
40
|
+
const ok = await this.confirm(`${what}\nDelete the style?`);
|
|
41
|
+
if (!ok)
|
|
42
|
+
this.error('Aborted. Nothing was deleted.');
|
|
43
|
+
}
|
|
44
|
+
let data;
|
|
45
|
+
try {
|
|
46
|
+
data = await client.delete(`${STYLE_FACES_PATH}/${encodeURIComponent(args.alias)}?scope=${SCOPE}`);
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
if (err instanceof FacesAPIError) {
|
|
50
|
+
if (err.statusCode === 404)
|
|
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.`);
|
|
54
|
+
this.error(`Error (${err.statusCode}): ${err.message}`);
|
|
55
|
+
}
|
|
56
|
+
throw err;
|
|
57
|
+
}
|
|
58
|
+
if (this.jsonEnabled())
|
|
59
|
+
return data;
|
|
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`);
|
|
62
|
+
return data;
|
|
63
|
+
}
|
|
64
|
+
confirm(message) {
|
|
65
|
+
return new Promise((resolve) => {
|
|
66
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
67
|
+
rl.question(`${message} [y/N] `, (answer) => {
|
|
68
|
+
rl.close();
|
|
69
|
+
resolve(answer.toLowerCase() === 'y');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { BaseCommand } from '../../base.js';
|
|
2
|
+
export default class StyleMake extends BaseCommand {
|
|
3
|
+
static description: string;
|
|
4
|
+
static examples: string[];
|
|
5
|
+
static flags: {
|
|
6
|
+
source: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
7
|
+
all: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
8
|
+
medium: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
9
|
+
model: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
|
+
'allow-paid': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
11
|
+
compile: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
12
|
+
'best-of': import("@oclif/core/interfaces").OptionFlag<number | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
13
|
+
'no-wait': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
14
|
+
timeout: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
|
|
15
|
+
'base-url': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
16
|
+
token: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
17
|
+
'api-key': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
18
|
+
};
|
|
19
|
+
static args: {
|
|
20
|
+
alias: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
|
|
21
|
+
};
|
|
22
|
+
run(): Promise<unknown>;
|
|
23
|
+
private load;
|
|
24
|
+
/**
|
|
25
|
+
* Turn the flags into the wire's source list.
|
|
26
|
+
*
|
|
27
|
+
* The API wants {id, medium, source_type} per source and deliberately refuses
|
|
28
|
+
* to guess the medium. The listing already knows all three for most sources,
|
|
29
|
+
* so a user only has to supply what genuinely cannot be derived.
|
|
30
|
+
*/
|
|
31
|
+
private select;
|
|
32
|
+
/**
|
|
33
|
+
* Refuse a build that would bill without being asked to.
|
|
34
|
+
*
|
|
35
|
+
* The analyst model runs the whole build, so it is the most expensive call in
|
|
36
|
+
* the product. Models on the free tier run on the user's own linked ChatGPT
|
|
37
|
+
* subscription; anything else bills every time. Reaching for a paid model to
|
|
38
|
+
* work around an expired link turns a fixable login into a permanent cost,
|
|
39
|
+
* which is why this is a refusal and not a warning.
|
|
40
|
+
*/
|
|
41
|
+
private checkBilling;
|
|
42
|
+
/** Translate a pre-flight refusal into the decision the user has to make. */
|
|
43
|
+
private reportStartFailure;
|
|
44
|
+
private awaitJob;
|
|
45
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { Args, Flags } from '@oclif/core';
|
|
2
|
+
import { BaseCommand } from '../../base.js';
|
|
3
|
+
import { FacesAPIError } from '../../client.js';
|
|
4
|
+
import { codexEligible } from '../../routing.js';
|
|
5
|
+
import { BUILDS_PATH, DEFAULT_STYLE_MODEL, formatBuildReport, jobFailureMessage, listSelectable, parseSourceArg, pollStyleJob, publicJob, } from '../../style.js';
|
|
6
|
+
export default class StyleMake extends BaseCommand {
|
|
7
|
+
static description = 'Capture how a face writes, from its own material, and install it. This is style, not knowledge: ' +
|
|
8
|
+
'compile teaches a face what it knows, style teaches it how it sounds. Name the sources to learn ' +
|
|
9
|
+
'from with --source, or take everything ready with --all.';
|
|
10
|
+
static examples = [
|
|
11
|
+
'<%= config.bin %> <%= command.id %> alice --all',
|
|
12
|
+
'<%= config.bin %> <%= command.id %> alice --source 1a2b3c4d --source 5e6f7a8b',
|
|
13
|
+
'<%= config.bin %> <%= command.id %> alice --source 1a2b3c4d:essay',
|
|
14
|
+
'<%= config.bin %> <%= command.id %> alice --all --medium essay --no-wait',
|
|
15
|
+
];
|
|
16
|
+
static flags = {
|
|
17
|
+
...BaseCommand.baseFlags,
|
|
18
|
+
source: Flags.string({
|
|
19
|
+
description: 'A document or thread to learn from, by id (repeatable). Append :<medium> to declare what it ' +
|
|
20
|
+
'is, e.g. --source 1a2b3c4d:essay, when the source does not already say.',
|
|
21
|
+
multiple: true,
|
|
22
|
+
}),
|
|
23
|
+
all: Flags.boolean({
|
|
24
|
+
description: 'Use every source on the face that is ready. Anything skipped is listed.',
|
|
25
|
+
default: false,
|
|
26
|
+
}),
|
|
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.',
|
|
30
|
+
}),
|
|
31
|
+
model: Flags.string({ description: `Analyst model (default: ${DEFAULT_STYLE_MODEL})` }),
|
|
32
|
+
'allow-paid': Flags.boolean({
|
|
33
|
+
description: 'Permit a build that bills. Without this a build is free or it does not run. Needed for any ' +
|
|
34
|
+
'model outside the free tier, and to allow the paid route when a free quota is exhausted.',
|
|
35
|
+
default: false,
|
|
36
|
+
}),
|
|
37
|
+
compile: Flags.boolean({
|
|
38
|
+
description: 'Compile any named source that is not compiled yet, before capturing style. On by default. ' +
|
|
39
|
+
'--no-compile captures style from what is already compiled and never bills for compilation.',
|
|
40
|
+
default: true,
|
|
41
|
+
allowNo: true,
|
|
42
|
+
}),
|
|
43
|
+
'best-of': Flags.integer({ description: 'Generate N candidates and keep the best (1-5, default 1)' }),
|
|
44
|
+
'no-wait': Flags.boolean({ description: 'Start the build and return the job id without polling.', default: false }),
|
|
45
|
+
timeout: Flags.integer({ description: 'How long to wait, in seconds (default: 3600)', default: 3600 }),
|
|
46
|
+
};
|
|
47
|
+
static args = {
|
|
48
|
+
alias: Args.string({ description: 'Face alias', required: true }),
|
|
49
|
+
};
|
|
50
|
+
async run() {
|
|
51
|
+
const { args, flags } = await this.parse(StyleMake);
|
|
52
|
+
const client = this.makeClient(flags);
|
|
53
|
+
const json = this.jsonEnabled();
|
|
54
|
+
if (!flags.all && (!flags.source || flags.source.length === 0)) {
|
|
55
|
+
this.error('Name what to learn from: --source <id> (repeatable), or --all for everything ready.\n' +
|
|
56
|
+
`See what is available with: faces face:sources ${args.alias}`);
|
|
57
|
+
}
|
|
58
|
+
if (flags.all && flags.source?.length) {
|
|
59
|
+
this.error('Pass either --all or --source, not both.');
|
|
60
|
+
}
|
|
61
|
+
const available = await this.load(client, args.alias);
|
|
62
|
+
const { sources, skipped } = this.select(available, flags, args.alias);
|
|
63
|
+
const model = flags.model ?? DEFAULT_STYLE_MODEL;
|
|
64
|
+
await this.checkBilling(client, model, flags['allow-paid']);
|
|
65
|
+
const payload = {
|
|
66
|
+
self_face: args.alias,
|
|
67
|
+
model,
|
|
68
|
+
sources,
|
|
69
|
+
compile: flags.compile,
|
|
70
|
+
};
|
|
71
|
+
if (flags['allow-paid'])
|
|
72
|
+
payload.oauth_only = false;
|
|
73
|
+
if (flags['best-of'] !== undefined)
|
|
74
|
+
payload.best_of_n = flags['best-of'];
|
|
75
|
+
if (!json && skipped.length > 0) {
|
|
76
|
+
for (const line of skipped)
|
|
77
|
+
process.stderr.write(`skipped: ${line}\n`);
|
|
78
|
+
}
|
|
79
|
+
if (!json) {
|
|
80
|
+
process.stderr.write(`Capturing style for '${args.alias}' from ${sources.length} source(s) using ${model}.\n`);
|
|
81
|
+
}
|
|
82
|
+
let started;
|
|
83
|
+
try {
|
|
84
|
+
started = (await client.post(BUILDS_PATH, { body: payload }));
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
this.reportStartFailure(err);
|
|
88
|
+
}
|
|
89
|
+
const jobId = String(started.job_id ?? '');
|
|
90
|
+
if (!jobId)
|
|
91
|
+
this.error('The server accepted the build but returned no job id.');
|
|
92
|
+
if (flags['no-wait']) {
|
|
93
|
+
if (json)
|
|
94
|
+
return { ...started, sources, skipped };
|
|
95
|
+
this.log(`Build started: ${jobId}`);
|
|
96
|
+
this.log(`Read it with: faces style:status ${jobId}`);
|
|
97
|
+
return started;
|
|
98
|
+
}
|
|
99
|
+
const final = await this.awaitJob(client, jobId, flags.timeout, json);
|
|
100
|
+
if (final.status === 'failed')
|
|
101
|
+
this.error(`Build failed: ${jobFailureMessage(final.error)}`);
|
|
102
|
+
if (json)
|
|
103
|
+
return { ...publicJob(final), sources, skipped };
|
|
104
|
+
for (const line of formatBuildReport(final))
|
|
105
|
+
this.log(line);
|
|
106
|
+
this.log('');
|
|
107
|
+
this.log(`'${args.alias}' now writes in its own style. Try it: faces chat:chat ${args.alias} -m "..."`);
|
|
108
|
+
this.log(`Go back to the previous style: faces style:revert ${args.alias} --yes`);
|
|
109
|
+
return final;
|
|
110
|
+
}
|
|
111
|
+
async load(client, alias) {
|
|
112
|
+
try {
|
|
113
|
+
return await listSelectable(client, alias);
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
if (err instanceof FacesAPIError) {
|
|
117
|
+
if (err.statusCode === 404)
|
|
118
|
+
this.error(`No face named '${alias}'. It does not exist, or is not yours.`);
|
|
119
|
+
this.error(`Error (${err.statusCode}): ${err.message}`);
|
|
120
|
+
}
|
|
121
|
+
throw err;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Turn the flags into the wire's source list.
|
|
126
|
+
*
|
|
127
|
+
* The API wants {id, medium, source_type} per source and deliberately refuses
|
|
128
|
+
* to guess the medium. The listing already knows all three for most sources,
|
|
129
|
+
* so a user only has to supply what genuinely cannot be derived.
|
|
130
|
+
*/
|
|
131
|
+
select(available, flags, alias) {
|
|
132
|
+
const byId = new Map(available.map((s) => [s.id, s]));
|
|
133
|
+
const skipped = [];
|
|
134
|
+
let chosen;
|
|
135
|
+
if (flags.all) {
|
|
136
|
+
if (available.length === 0) {
|
|
137
|
+
this.error(`'${alias}' has no sources to learn from. Add some with faces compile:doc or faces style:upload.`);
|
|
138
|
+
}
|
|
139
|
+
chosen = [];
|
|
140
|
+
for (const s of available) {
|
|
141
|
+
// Imported material and documents go in whether or not they are
|
|
142
|
+
// compiled: compiling them is part of the build, which is what
|
|
143
|
+
// --compile is for, and a corpus is never compiled at upload time.
|
|
144
|
+
//
|
|
145
|
+
// A live thread is the exception. It compiles as its messages arrive
|
|
146
|
+
// and is billed per message, so sweeping an unfinished one would charge
|
|
147
|
+
// for turns the user did not send. Those wait to be named.
|
|
148
|
+
if (s.isCorpus || s.synced) {
|
|
149
|
+
if (!flags.compile && !s.synced) {
|
|
150
|
+
skipped.push(`${s.label} (${s.id}) is not compiled, and --no-compile means it would teach nothing.`);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
chosen.push({ s });
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
skipped.push(`${s.label} (${s.id}) is a thread that is still compiling. Name it with --source once it is done.`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (chosen.length === 0) {
|
|
160
|
+
this.error(`Nothing on '${alias}' is ready to learn from.\n` +
|
|
161
|
+
(flags.compile
|
|
162
|
+
? 'Add material with faces style:upload or faces compile:doc, then try again.'
|
|
163
|
+
: '--no-compile only uses sources that are already compiled, and none are. Drop it to compile them as part of this build.'));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
chosen = [];
|
|
168
|
+
const missing = [];
|
|
169
|
+
for (const raw of flags.source ?? []) {
|
|
170
|
+
const { id, medium } = parseSourceArg(raw);
|
|
171
|
+
const s = byId.get(id);
|
|
172
|
+
if (s)
|
|
173
|
+
chosen.push({ s, declared: medium });
|
|
174
|
+
else
|
|
175
|
+
missing.push(id);
|
|
176
|
+
}
|
|
177
|
+
if (missing.length > 0) {
|
|
178
|
+
this.error(`Not a source on '${alias}': ${missing.join(', ')}\n` +
|
|
179
|
+
`List what is there with: faces face:sources ${alias}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const undeclared = [];
|
|
183
|
+
const sources = [];
|
|
184
|
+
for (const { s, declared } of chosen) {
|
|
185
|
+
const medium = declared ?? flags.medium ?? s.medium;
|
|
186
|
+
if (!medium) {
|
|
187
|
+
undeclared.push(s);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
sources.push({ id: s.id, medium, source_type: s.sourceType });
|
|
191
|
+
}
|
|
192
|
+
if (undeclared.length > 0) {
|
|
193
|
+
const list = undeclared
|
|
194
|
+
.map((s) => ` ${s.id} ${s.label}${s.sourceType === 'room' ? ' (thread)' : ''}`)
|
|
195
|
+
.join('\n');
|
|
196
|
+
const first = undeclared[0];
|
|
197
|
+
const ways = [
|
|
198
|
+
' --medium email applies to every source that does not say',
|
|
199
|
+
` --source ${first.id}:email applies to just this one`,
|
|
200
|
+
];
|
|
201
|
+
// Only a document can record a medium on itself; a thread has no such field.
|
|
202
|
+
if (first.sourceType === 'document') {
|
|
203
|
+
ways.push(` faces compile:doc:edit ${first.id} --medium email records it permanently`);
|
|
204
|
+
}
|
|
205
|
+
this.error(`These sources do not say what sort of writing they are, and a style build will not guess:\n${list}\n\n` +
|
|
206
|
+
`Declare it, using the real medium rather than the example:\n${ways.join('\n')}\n\n` +
|
|
207
|
+
'A wrong declaration is worse than a missing one: a mislabelled source teaches the wrong voice ' +
|
|
208
|
+
'for that medium, and nothing afterwards says it happened.');
|
|
209
|
+
}
|
|
210
|
+
return { sources, skipped };
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Refuse a build that would bill without being asked to.
|
|
214
|
+
*
|
|
215
|
+
* The analyst model runs the whole build, so it is the most expensive call in
|
|
216
|
+
* the product. Models on the free tier run on the user's own linked ChatGPT
|
|
217
|
+
* subscription; anything else bills every time. Reaching for a paid model to
|
|
218
|
+
* work around an expired link turns a fixable login into a permanent cost,
|
|
219
|
+
* which is why this is a refusal and not a warning.
|
|
220
|
+
*/
|
|
221
|
+
async checkBilling(client, model, allowPaid) {
|
|
222
|
+
if (allowPaid)
|
|
223
|
+
return;
|
|
224
|
+
const eligible = await codexEligible(client, model);
|
|
225
|
+
if (eligible === false) {
|
|
226
|
+
this.error(`'${model}' is not on the free tier, so this build would bill you.\n` +
|
|
227
|
+
`Use ${DEFAULT_STYLE_MODEL}, which runs on your linked ChatGPT account at no cost, ` +
|
|
228
|
+
'or re-run with --allow-paid if you meant to pay.\n' +
|
|
229
|
+
'If you are here because a build failed on authentication, reconnect instead: faces auth:connect openai');
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/** Translate a pre-flight refusal into the decision the user has to make. */
|
|
233
|
+
reportStartFailure(err) {
|
|
234
|
+
if (!(err instanceof FacesAPIError))
|
|
235
|
+
throw err;
|
|
236
|
+
// Every one of these lands before the job exists, so nothing has started.
|
|
237
|
+
const hint = {
|
|
238
|
+
402: 'Add credit with: faces billing:topup',
|
|
239
|
+
403: 'That source belongs to another account.',
|
|
240
|
+
404: 'Check the id against: faces face:sources <alias>',
|
|
241
|
+
409: 'A source is compiling right now, or the face is locked. Wait for it to finish, or unlock the face.',
|
|
242
|
+
422: 'A named thread has messages that are not compiled yet. Compile it first, or pass --no-compile.',
|
|
243
|
+
};
|
|
244
|
+
const extra = hint[err.statusCode];
|
|
245
|
+
this.error(`Error (${err.statusCode}): ${err.message}${extra ? `\n${extra}` : ''}\nNothing was started.`);
|
|
246
|
+
}
|
|
247
|
+
async awaitJob(client, jobId, timeout, json) {
|
|
248
|
+
let last = '';
|
|
249
|
+
try {
|
|
250
|
+
return await pollStyleJob(client, BUILDS_PATH, jobId, {
|
|
251
|
+
timeoutMs: timeout * 1000,
|
|
252
|
+
onPoll: (d) => {
|
|
253
|
+
if (json)
|
|
254
|
+
return;
|
|
255
|
+
const p = d.progress;
|
|
256
|
+
const stage = p?.stage ?? String(d.status ?? '');
|
|
257
|
+
const key = `${stage}:${p?.done ?? ''}/${p?.total ?? ''}`;
|
|
258
|
+
if (key === last)
|
|
259
|
+
return;
|
|
260
|
+
last = key;
|
|
261
|
+
process.stderr.write(` ${stage}${p?.total ? ` ${p.done ?? 0}/${p.total}` : ''}\n`);
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
catch (err) {
|
|
266
|
+
if (err instanceof FacesAPIError)
|
|
267
|
+
this.error(`Error (${err.statusCode}): ${err.message}`);
|
|
268
|
+
this.error(err instanceof Error ? err.message : String(err));
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { BaseCommand } from '../../base.js';
|
|
2
|
+
export default class StyleRevert extends BaseCommand {
|
|
3
|
+
static description: string;
|
|
4
|
+
static examples: string[];
|
|
5
|
+
static flags: {
|
|
6
|
+
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
7
|
+
'base-url': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
8
|
+
token: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
9
|
+
'api-key': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
|
+
};
|
|
11
|
+
static args: {
|
|
12
|
+
alias: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
|
|
13
|
+
};
|
|
14
|
+
run(): Promise<unknown>;
|
|
15
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Args, Flags } from '@oclif/core';
|
|
2
|
+
import { BaseCommand } from '../../base.js';
|
|
3
|
+
import { FacesAPIError } from '../../client.js';
|
|
4
|
+
import { VERSIONS_PATH } from '../../style.js';
|
|
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'];
|
|
10
|
+
static flags = {
|
|
11
|
+
...BaseCommand.baseFlags,
|
|
12
|
+
yes: Flags.boolean({ description: 'Skip confirmation', default: false }),
|
|
13
|
+
};
|
|
14
|
+
static args = {
|
|
15
|
+
alias: Args.string({ description: 'Face alias', required: true }),
|
|
16
|
+
};
|
|
17
|
+
async run() {
|
|
18
|
+
const { args, flags } = await this.parse(StyleRevert);
|
|
19
|
+
const client = this.makeClient(flags);
|
|
20
|
+
// This writes to a live face the moment it is called. Requiring --yes is
|
|
21
|
+
// not ceremony: the endpoint takes a single field and gives no dry run, so
|
|
22
|
+
// an exploratory call is indistinguishable from a real one.
|
|
23
|
+
if (!flags.yes) {
|
|
24
|
+
this.error(`This changes how '${args.alias}' writes, straight away. Re-run with --yes to confirm.\n` +
|
|
25
|
+
`See what it would go back to: faces style:versions ${args.alias}`);
|
|
26
|
+
}
|
|
27
|
+
let data;
|
|
28
|
+
try {
|
|
29
|
+
data = (await client.post(`${VERSIONS_PATH}/revert`, { body: { self_face: args.alias } }));
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
if (err instanceof FacesAPIError) {
|
|
33
|
+
if (err.statusCode === 404)
|
|
34
|
+
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).
|
|
39
|
+
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);
|
|
44
|
+
}
|
|
45
|
+
this.error(`Error (${err.statusCode}): ${err.message}`);
|
|
46
|
+
}
|
|
47
|
+
throw err;
|
|
48
|
+
}
|
|
49
|
+
if (this.jsonEnabled())
|
|
50
|
+
return data;
|
|
51
|
+
const from = data.from_version;
|
|
52
|
+
const to = data.to_version;
|
|
53
|
+
if (from !== undefined && to !== undefined) {
|
|
54
|
+
this.log(`'${args.alias}' moved from version ${from} back to version ${to}.`);
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
this.log(`'${args.alias}' reverted.`);
|
|
58
|
+
}
|
|
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`);
|
|
62
|
+
this.log(`List versions: faces style:versions ${args.alias}`);
|
|
63
|
+
return data;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { BaseCommand } from '../../base.js';
|
|
2
|
+
export default class StyleStatus extends BaseCommand {
|
|
3
|
+
static description: string;
|
|
4
|
+
static examples: string[];
|
|
5
|
+
static flags: {
|
|
6
|
+
face: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
7
|
+
limit: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
|
|
8
|
+
wait: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
9
|
+
timeout: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
|
+
'base-url': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
|
+
token: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
12
|
+
'api-key': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
13
|
+
};
|
|
14
|
+
static args: {
|
|
15
|
+
job_id: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
|
|
16
|
+
};
|
|
17
|
+
run(): Promise<unknown>;
|
|
18
|
+
private list;
|
|
19
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { Args, Flags } from '@oclif/core';
|
|
2
|
+
import { BaseCommand } from '../../base.js';
|
|
3
|
+
import { FacesAPIError } from '../../client.js';
|
|
4
|
+
import { BUILDS_PATH, EXIT_NO_SUCH_FACE, formatBuildReport, jobFailureMessage, faceExists, pollStyleJob, publicJob } from '../../style.js';
|
|
5
|
+
export default class StyleStatus extends BaseCommand {
|
|
6
|
+
static description = 'Read a style build. Give a job id for one build, or --face to list a face\'s recent builds. ' +
|
|
7
|
+
'Listing is how you find a job id you no longer have.';
|
|
8
|
+
static examples = [
|
|
9
|
+
'<%= config.bin %> <%= command.id %> 7a8b9c0d',
|
|
10
|
+
'<%= config.bin %> <%= command.id %> --face alice',
|
|
11
|
+
'<%= config.bin %> <%= command.id %> 7a8b9c0d --wait',
|
|
12
|
+
];
|
|
13
|
+
static flags = {
|
|
14
|
+
...BaseCommand.baseFlags,
|
|
15
|
+
face: Flags.string({ description: 'List recent builds for this face instead of reading one job' }),
|
|
16
|
+
limit: Flags.integer({ description: 'How many builds to list with --face (default: 10)', default: 10 }),
|
|
17
|
+
wait: Flags.boolean({ description: 'Block until the job finishes', default: false }),
|
|
18
|
+
timeout: Flags.integer({ description: 'How long to wait with --wait, in seconds (default: 3600)', default: 3600 }),
|
|
19
|
+
};
|
|
20
|
+
static args = {
|
|
21
|
+
job_id: Args.string({ description: 'Build job id', required: false }),
|
|
22
|
+
};
|
|
23
|
+
async run() {
|
|
24
|
+
const { args, flags } = await this.parse(StyleStatus);
|
|
25
|
+
const client = this.makeClient(flags);
|
|
26
|
+
const json = this.jsonEnabled();
|
|
27
|
+
if (!args.job_id && !flags.face) {
|
|
28
|
+
this.error('Give a job id, or --face <alias> to list a face\'s builds.');
|
|
29
|
+
}
|
|
30
|
+
if (args.job_id && flags.face) {
|
|
31
|
+
this.error('Pass either a job id or --face, not both.');
|
|
32
|
+
}
|
|
33
|
+
if (flags.face)
|
|
34
|
+
return this.list(client, flags.face, flags.limit, json);
|
|
35
|
+
let data;
|
|
36
|
+
try {
|
|
37
|
+
data = flags.wait
|
|
38
|
+
? await pollStyleJob(client, BUILDS_PATH, args.job_id, { timeoutMs: flags.timeout * 1000 })
|
|
39
|
+
: (await client.get(`${BUILDS_PATH}/${encodeURIComponent(args.job_id)}`));
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
if (err instanceof FacesAPIError) {
|
|
43
|
+
if (err.statusCode === 404)
|
|
44
|
+
this.error(`No build job '${args.job_id}'. List a face's builds with --face <alias>.`);
|
|
45
|
+
this.error(`Error (${err.statusCode}): ${err.message}`);
|
|
46
|
+
}
|
|
47
|
+
throw err;
|
|
48
|
+
}
|
|
49
|
+
if (json)
|
|
50
|
+
return publicJob(data);
|
|
51
|
+
for (const line of formatBuildReport(data))
|
|
52
|
+
this.log(line);
|
|
53
|
+
if (data.status === 'failed')
|
|
54
|
+
this.log(`\nerror: ${jobFailureMessage(data.error)}`);
|
|
55
|
+
return data;
|
|
56
|
+
}
|
|
57
|
+
async list(client, face, limit, json) {
|
|
58
|
+
let data;
|
|
59
|
+
try {
|
|
60
|
+
data = (await client.get(`${BUILDS_PATH}?self_face=${encodeURIComponent(face)}&limit=${limit}`));
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
if (err instanceof FacesAPIError) {
|
|
64
|
+
if (err.statusCode === 404)
|
|
65
|
+
this.error(`No face named '${face}'. It does not exist, or is not yours.`);
|
|
66
|
+
this.error(`Error (${err.statusCode}): ${err.message}`);
|
|
67
|
+
}
|
|
68
|
+
throw err;
|
|
69
|
+
}
|
|
70
|
+
const jobs = data.jobs ?? [];
|
|
71
|
+
// An empty list comes back for an unknown face too. "No builds yet" and
|
|
72
|
+
// "no such face" are opposite answers, so they must not share an output.
|
|
73
|
+
if (jobs.length === 0 && !(await faceExists(client, face))) {
|
|
74
|
+
this.error(`No face named '${face}'. It does not exist, or is not yours.`, { exit: EXIT_NO_SUCH_FACE });
|
|
75
|
+
}
|
|
76
|
+
if (json)
|
|
77
|
+
return { ...data, jobs: jobs.map((j) => publicJob(j)) };
|
|
78
|
+
if (jobs.length === 0) {
|
|
79
|
+
this.log(`'${face}' has no style builds yet. Capture one with: faces style:make ${face} --all`);
|
|
80
|
+
return data;
|
|
81
|
+
}
|
|
82
|
+
const rows = jobs.map((j) => ({
|
|
83
|
+
job: String(j.job_id ?? '-'),
|
|
84
|
+
status: String(j.status ?? '-'),
|
|
85
|
+
when: String(j.created_at ?? '').slice(0, 19).replace('T', ' ') || '-',
|
|
86
|
+
model: String(j.report?.model ?? j.model ?? '-'),
|
|
87
|
+
}));
|
|
88
|
+
const cols = [
|
|
89
|
+
['JOB', (r) => r.job],
|
|
90
|
+
['STATUS', (r) => r.status],
|
|
91
|
+
['STARTED', (r) => r.when],
|
|
92
|
+
];
|
|
93
|
+
const w = cols.map(([h, g]) => Math.max(h.length, ...rows.map((r) => g(r).length)));
|
|
94
|
+
const line = (c) => c.map((x, i) => x.padEnd(w[i])).join(' ').trimEnd();
|
|
95
|
+
this.log(line(cols.map(([h]) => h)));
|
|
96
|
+
for (const r of rows)
|
|
97
|
+
this.log(line(cols.map(([, g]) => g(r))));
|
|
98
|
+
this.log('');
|
|
99
|
+
this.log(`${rows.length} build(s) shown, newest first. Raise the cap with --limit.`);
|
|
100
|
+
return data;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { BaseCommand } from '../../base.js';
|
|
2
|
+
export default class StyleUpload extends BaseCommand {
|
|
3
|
+
static description: string;
|
|
4
|
+
static examples: string[];
|
|
5
|
+
static flags: {
|
|
6
|
+
type: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
7
|
+
'messages-per-room': import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
|
|
8
|
+
strict: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
9
|
+
'base-url': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
|
+
token: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
|
+
'api-key': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
12
|
+
};
|
|
13
|
+
static args: {
|
|
14
|
+
alias: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
|
|
15
|
+
file: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
|
|
16
|
+
};
|
|
17
|
+
run(): Promise<unknown>;
|
|
18
|
+
}
|