@theelena/catalog-cli 0.0.0-stage → 0.1.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/index.d.ts +1 -0
- package/dist/index.js +971 -0
- package/package.json +34 -4
- package/README.md +0 -3
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,971 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { setTimeout as sleep } from "timers/promises";
|
|
5
|
+
import {
|
|
6
|
+
createElenaAgentClient as createElenaAgentClient2,
|
|
7
|
+
elenaAgentImportPreviewRequestSchema,
|
|
8
|
+
elenaAgentProposalRequestSchema
|
|
9
|
+
} from "@theelena/sdk/agent";
|
|
10
|
+
|
|
11
|
+
// src/arguments.ts
|
|
12
|
+
import {
|
|
13
|
+
elenaAgentApplyRequestSchema,
|
|
14
|
+
elenaAgentIdSchema,
|
|
15
|
+
elenaAgentPageQuerySchema,
|
|
16
|
+
elenaAgentRefSchema
|
|
17
|
+
} from "@theelena/sdk/agent";
|
|
18
|
+
|
|
19
|
+
// src/security.ts
|
|
20
|
+
import {
|
|
21
|
+
elenaAgentConnectionOriginSchema,
|
|
22
|
+
elenaAgentErrorCodeSchema
|
|
23
|
+
} from "@theelena/sdk/agent";
|
|
24
|
+
var messages = {
|
|
25
|
+
INVALID_REQUEST: "Comando, configuraci\xF3n o archivo inv\xE1lido. Consulte --help.",
|
|
26
|
+
CREDENTIAL_REQUIRED: "Inicie sesi\xF3n o configure el par de variables de entorno protegidas.",
|
|
27
|
+
CREDENTIAL_INVALID: "Credencial no v\xE1lida. Inicie una nueva conexi\xF3n.",
|
|
28
|
+
CREDENTIAL_EXPIRED: "La credencial venci\xF3. Inicie una nueva conexi\xF3n.",
|
|
29
|
+
CREDENTIAL_REVOKED: "La credencial fue revocada. Inicie una nueva conexi\xF3n.",
|
|
30
|
+
ORIGIN_REJECTED: "El origen no coincide con la configuraci\xF3n fijada de la conexi\xF3n.",
|
|
31
|
+
TEMPORARY_UNAVAILABLE: "No se pudo confirmar la respuesta. Consulte el estado antes de repetir una mutaci\xF3n.",
|
|
32
|
+
PAIRING_CONSUMED: "La entrega ya fue consumida. Inicie otra conexi\xF3n y solicite revocar la credencial hu\xE9rfana.",
|
|
33
|
+
PAIRING_EXPIRED: "La conexi\xF3n venci\xF3. Inicie otra conexi\xF3n.",
|
|
34
|
+
PAIRING_DENIED: "El due\xF1o rechaz\xF3 la conexi\xF3n.",
|
|
35
|
+
VERSION_CONFLICT: "La versi\xF3n cambi\xF3. Relea el recurso y cree una nueva propuesta.",
|
|
36
|
+
MEDIA_INVALID: "Archivo local inv\xE1lido. Use JPEG, PNG o WebP est\xE1tico de hasta 10 MiB."
|
|
37
|
+
};
|
|
38
|
+
function exitFor(code) {
|
|
39
|
+
if ([
|
|
40
|
+
"INVALID_REQUEST",
|
|
41
|
+
"UNSUPPORTED_VERSION",
|
|
42
|
+
"CREDENTIAL_MIXED",
|
|
43
|
+
"CREDENTIAL_WRONG_FAMILY",
|
|
44
|
+
"INVALID_PRICE",
|
|
45
|
+
"UNCONFIRMED_DATA",
|
|
46
|
+
"MEDIA_INVALID"
|
|
47
|
+
].includes(code))
|
|
48
|
+
return 2;
|
|
49
|
+
if ([
|
|
50
|
+
"CREDENTIAL_REQUIRED",
|
|
51
|
+
"CREDENTIAL_INVALID",
|
|
52
|
+
"CREDENTIAL_EXPIRED",
|
|
53
|
+
"CREDENTIAL_REVOKED",
|
|
54
|
+
"OWNER_REQUIRED",
|
|
55
|
+
"ORIGIN_REJECTED",
|
|
56
|
+
"APPROVAL_REQUIRED",
|
|
57
|
+
"APPROVAL_REJECTED",
|
|
58
|
+
"SCOPE_REQUIRED",
|
|
59
|
+
"MODE_READ_ONLY",
|
|
60
|
+
"API_ACCESS_DISABLED",
|
|
61
|
+
"RESTAURANT_NOT_APPROVED",
|
|
62
|
+
"PAIRING_DENIED"
|
|
63
|
+
].includes(code))
|
|
64
|
+
return 3;
|
|
65
|
+
if ([
|
|
66
|
+
"QUOTA_EXCEEDED",
|
|
67
|
+
"RATE_LIMITED",
|
|
68
|
+
"TEMPORARY_UNAVAILABLE",
|
|
69
|
+
"INTERNAL_ERROR",
|
|
70
|
+
"PAIRING_PENDING"
|
|
71
|
+
].includes(code))
|
|
72
|
+
return 5;
|
|
73
|
+
return 4;
|
|
74
|
+
}
|
|
75
|
+
var CliError = class extends Error {
|
|
76
|
+
code;
|
|
77
|
+
retryAfterSeconds;
|
|
78
|
+
constructor(code, retryAfterSeconds) {
|
|
79
|
+
super(messages[code] ?? `No se pudo completar la operaci\xF3n (${code}).`);
|
|
80
|
+
this.name = "CliError";
|
|
81
|
+
this.code = code;
|
|
82
|
+
if (Number.isInteger(retryAfterSeconds) && retryAfterSeconds >= 1 && retryAfterSeconds <= 3600) {
|
|
83
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
toWire() {
|
|
87
|
+
return {
|
|
88
|
+
version: "1",
|
|
89
|
+
error: {
|
|
90
|
+
code: this.code,
|
|
91
|
+
message: this.message,
|
|
92
|
+
retryable: this.code === "RATE_LIMITED" || this.code === "TEMPORARY_UNAVAILABLE",
|
|
93
|
+
requestId: "cli-local",
|
|
94
|
+
...this.retryAfterSeconds ? { retryAfterSeconds: this.retryAfterSeconds } : {}
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
function safeError(error) {
|
|
100
|
+
if (error instanceof CliError) return error;
|
|
101
|
+
if (error && typeof error === "object" && "code" in error) {
|
|
102
|
+
if (error.code === "REDIRECT_REJECTED") return new CliError("ORIGIN_REJECTED");
|
|
103
|
+
const parsed = elenaAgentErrorCodeSchema.safeParse(error.code);
|
|
104
|
+
if (parsed.success) {
|
|
105
|
+
const retry = "retryAfterSeconds" in error ? error.retryAfterSeconds : void 0;
|
|
106
|
+
return new CliError(parsed.data, typeof retry === "number" ? retry : void 0);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return new CliError("TEMPORARY_UNAVAILABLE");
|
|
110
|
+
}
|
|
111
|
+
function parse(schema, value) {
|
|
112
|
+
const result = schema.safeParse(value);
|
|
113
|
+
if (!result.success) throw new CliError("INVALID_REQUEST");
|
|
114
|
+
return result.data;
|
|
115
|
+
}
|
|
116
|
+
function origin(value) {
|
|
117
|
+
const valid = parse(elenaAgentConnectionOriginSchema, value);
|
|
118
|
+
if (new URL(valid).protocol !== "https:" && process.env.NODE_ENV !== "development") {
|
|
119
|
+
throw new CliError("INVALID_REQUEST");
|
|
120
|
+
}
|
|
121
|
+
if (credentialPattern.test(valid)) throw new CliError("INVALID_REQUEST");
|
|
122
|
+
return new URL(valid).origin;
|
|
123
|
+
}
|
|
124
|
+
function pinUrl(value, expectedOrigin) {
|
|
125
|
+
let url;
|
|
126
|
+
try {
|
|
127
|
+
url = new URL(value);
|
|
128
|
+
} catch {
|
|
129
|
+
throw new CliError("ORIGIN_REJECTED");
|
|
130
|
+
}
|
|
131
|
+
if (url.origin !== expectedOrigin || url.protocol !== "https:" && !(process.env.NODE_ENV === "development" && url.protocol === "http:") || url.username || url.password || // eslint-disable-next-line no-control-regex -- Intentionally reject U+0000-U+001F in untrusted URLs.
|
|
132
|
+
/[\\\s\u0000-\u001f]/.test(value)) {
|
|
133
|
+
throw new CliError("ORIGIN_REJECTED");
|
|
134
|
+
}
|
|
135
|
+
return value;
|
|
136
|
+
}
|
|
137
|
+
var credentialPattern = /(?:elena_(?:sk|pk)_|\bsk_)[A-Za-z0-9_-]+/;
|
|
138
|
+
var SecretGuard = class {
|
|
139
|
+
#secrets = /* @__PURE__ */ new Set();
|
|
140
|
+
add(value) {
|
|
141
|
+
if (value) this.#secrets.add(value);
|
|
142
|
+
}
|
|
143
|
+
contains(value) {
|
|
144
|
+
let candidate = value;
|
|
145
|
+
for (let layer = 0; layer < 3; layer++) {
|
|
146
|
+
if (credentialPattern.test(candidate) || [...this.#secrets].some((secret) => candidate.includes(secret)))
|
|
147
|
+
return true;
|
|
148
|
+
try {
|
|
149
|
+
const decoded = decodeURIComponent(candidate);
|
|
150
|
+
if (decoded === candidate) break;
|
|
151
|
+
candidate = decoded;
|
|
152
|
+
} catch {
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
assertSafe(value) {
|
|
159
|
+
const serialized = typeof value === "string" ? value : JSON.stringify(value);
|
|
160
|
+
if (serialized && this.contains(serialized)) throw new CliError("INVALID_REQUEST");
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
// src/arguments.ts
|
|
165
|
+
var grammar = {
|
|
166
|
+
"auth login": { args: 0, required: ["--base-url"], optional: ["--no-open"] },
|
|
167
|
+
"auth status": { args: 0, required: [], optional: [] },
|
|
168
|
+
"auth logout": { args: 0, required: [], optional: [] },
|
|
169
|
+
context: { args: 0, required: [], optional: [] },
|
|
170
|
+
"catalog categories list": { args: 0, required: [], optional: ["--cursor", "--limit"] },
|
|
171
|
+
"catalog categories get": { args: 1, required: [], optional: [] },
|
|
172
|
+
"catalog products list": { args: 0, required: [], optional: ["--cursor", "--limit"] },
|
|
173
|
+
"catalog products get": { args: 1, required: [], optional: [] },
|
|
174
|
+
"assets get": { args: 1, required: [], optional: [] },
|
|
175
|
+
"media stage": { args: 1, required: ["--idempotency-key"], optional: [] },
|
|
176
|
+
"media status": { args: 1, required: [], optional: [] },
|
|
177
|
+
"import preview": { args: 0, required: ["--file"], optional: [] },
|
|
178
|
+
"import status": { args: 1, required: [], optional: [] },
|
|
179
|
+
"operations propose": { args: 0, required: ["--file"], optional: [] },
|
|
180
|
+
"operations list": { args: 0, required: [], optional: ["--cursor", "--limit"] },
|
|
181
|
+
"operations status": { args: 1, required: [], optional: [] },
|
|
182
|
+
"operations apply": { args: 1, required: ["--revision", "--payload-hash"], optional: [] }
|
|
183
|
+
};
|
|
184
|
+
function integer(value) {
|
|
185
|
+
if (!/^[1-9]\d*$/.test(value) || !Number.isSafeInteger(Number(value)))
|
|
186
|
+
throw new CliError("INVALID_REQUEST");
|
|
187
|
+
return Number(value);
|
|
188
|
+
}
|
|
189
|
+
function argumentsFor(argv, guard) {
|
|
190
|
+
guard.assertSafe(argv);
|
|
191
|
+
const json = argv.includes("--json");
|
|
192
|
+
if (argv.filter((arg) => arg === "--json").length > 1) throw new CliError("INVALID_REQUEST");
|
|
193
|
+
const tokens = argv.filter((arg) => arg !== "--json");
|
|
194
|
+
if (tokens.length === 0 || tokens.length === 1 && tokens[0] === "--help")
|
|
195
|
+
return { command: "help", args: [], flags: {}, json };
|
|
196
|
+
if (tokens.length === 1 && tokens[0] === "--version")
|
|
197
|
+
return { command: "version", args: [], flags: {}, json };
|
|
198
|
+
const command = Object.keys(grammar).find(
|
|
199
|
+
(name) => name.split(" ").every((part, index) => tokens[index] === part)
|
|
200
|
+
);
|
|
201
|
+
if (!command) throw new CliError("INVALID_REQUEST");
|
|
202
|
+
const definition = grammar[command];
|
|
203
|
+
const rest = tokens.slice(command.split(" ").length);
|
|
204
|
+
if (rest.length === 1 && rest[0] === "--help")
|
|
205
|
+
return { command: "help", args: [], flags: {}, json };
|
|
206
|
+
const args = [];
|
|
207
|
+
const flags = /* @__PURE__ */ Object.create(null);
|
|
208
|
+
for (let index = 0; index < rest.length; index++) {
|
|
209
|
+
const token = rest[index];
|
|
210
|
+
if (!token.startsWith("-")) {
|
|
211
|
+
args.push(token);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (!definition.required.includes(token) && !definition.optional.includes(token) || token in flags)
|
|
215
|
+
throw new CliError("INVALID_REQUEST");
|
|
216
|
+
if (token === "--no-open") {
|
|
217
|
+
flags[token] = "true";
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
const value = rest[++index];
|
|
221
|
+
if (!value || value.startsWith("-")) throw new CliError("INVALID_REQUEST");
|
|
222
|
+
flags[token] = value;
|
|
223
|
+
}
|
|
224
|
+
if (args.length !== definition.args || definition.required.some((flag) => !(flag in flags)))
|
|
225
|
+
throw new CliError("INVALID_REQUEST");
|
|
226
|
+
if (command === "auth login") origin(flags["--base-url"]);
|
|
227
|
+
if (definition.args === 1 && command !== "media stage") parse(elenaAgentIdSchema, args[0]);
|
|
228
|
+
if (command.endsWith(" list")) pageQuery(flags);
|
|
229
|
+
if (command === "media stage") parse(elenaAgentRefSchema, flags["--idempotency-key"]);
|
|
230
|
+
if (command === "operations apply") applyRequest(flags);
|
|
231
|
+
return { command, args, flags, json };
|
|
232
|
+
}
|
|
233
|
+
function pageQuery(flags) {
|
|
234
|
+
return parse(elenaAgentPageQuerySchema, {
|
|
235
|
+
version: "1",
|
|
236
|
+
...flags["--cursor"] !== void 0 ? { cursor: flags["--cursor"] } : {},
|
|
237
|
+
...flags["--limit"] !== void 0 ? { limit: integer(flags["--limit"]) } : {}
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
function applyRequest(flags) {
|
|
241
|
+
return parse(elenaAgentApplyRequestSchema, {
|
|
242
|
+
version: "1",
|
|
243
|
+
expectedRevision: integer(flags["--revision"]),
|
|
244
|
+
payloadHash: flags["--payload-hash"]
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
var HELP = `elena-catalog 0.1.0 \u2014 cat\xE1logo de un restaurante, con aprobaci\xF3n humana
|
|
248
|
+
|
|
249
|
+
Comandos (salida JSON estable; --json disponible globalmente):
|
|
250
|
+
auth login --base-url <https-origin> [--no-open]
|
|
251
|
+
auth status
|
|
252
|
+
auth logout
|
|
253
|
+
context
|
|
254
|
+
catalog categories list [--cursor <cursor>] [--limit <n>]
|
|
255
|
+
catalog categories get <id>
|
|
256
|
+
catalog products list [--cursor <cursor>] [--limit <n>]
|
|
257
|
+
catalog products get <id>
|
|
258
|
+
assets get <id>
|
|
259
|
+
media stage <local-file> --idempotency-key <ref>
|
|
260
|
+
media status <staging-id>
|
|
261
|
+
import preview --file <json-file>
|
|
262
|
+
import status <import-id>
|
|
263
|
+
operations propose --file <json-file>
|
|
264
|
+
operations list [--cursor <cursor>] [--limit <n>]
|
|
265
|
+
operations status <operation-id>
|
|
266
|
+
operations apply <operation-id> --revision <n> --payload-hash <sha256>
|
|
267
|
+
|
|
268
|
+
Conexi\xF3n: configure ELENA_AGENT_STAFF_ORIGIN con el origen HTTPS del panel.
|
|
269
|
+
El navegador no se abre autom\xE1ticamente; use la URL literal y el c\xF3digo mostrados.
|
|
270
|
+
Alternativa protegida: ELENA_AGENT_KEY + ELENA_AGENT_BASE_URL en el entorno.
|
|
271
|
+
No escriba secretos en argumentos ni archivos JSON. No hay origen de producci\xF3n por defecto.
|
|
272
|
+
La conexi\xF3n fija los or\xEDgenes. Para otro origen, cierre primero la sesi\xF3n local.
|
|
273
|
+
Logout no revoca la clave remota ni elimina variables del proceso padre.
|
|
274
|
+
Proponer no aplica. Aprueba el due\xF1o en el panel; apply exige revisi\xF3n y hash exactos.
|
|
275
|
+
Actualizar reemplaza la proyecci\xF3n editable completa: relea antes de preparar el archivo.
|
|
276
|
+
Publicar es una propuesta separada; disponibilidad no significa publicaci\xF3n.
|
|
277
|
+
Archivos preview/propose: solicitud V1 completa, strict, m\xE1ximo 1 MiB.
|
|
278
|
+
Im\xE1genes: archivos locales JPEG/PNG/WebP; m\xE1ximo 10 MiB. No URLs remotas.
|
|
279
|
+
Salidas: 0 \xE9xito; 2 uso/schema; 3 acceso/aprobaci\xF3n; 4 conflicto/terminal fallido; 5 transitorio/red.
|
|
280
|
+
`;
|
|
281
|
+
|
|
282
|
+
// src/auth.ts
|
|
283
|
+
import {
|
|
284
|
+
elenaAgentErrorSchema,
|
|
285
|
+
elenaAgentPairingPollResponseSchema,
|
|
286
|
+
elenaAgentPairingStartResponseSchema
|
|
287
|
+
} from "@theelena/sdk/agent";
|
|
288
|
+
|
|
289
|
+
// src/store.ts
|
|
290
|
+
import { constants } from "fs";
|
|
291
|
+
import { lstat, mkdir, open, rename, unlink } from "fs/promises";
|
|
292
|
+
import { join, parse as parsePath, resolve } from "path";
|
|
293
|
+
import { randomUUID } from "crypto";
|
|
294
|
+
import { homedir } from "os";
|
|
295
|
+
import {
|
|
296
|
+
createElenaAgentClient,
|
|
297
|
+
elenaAgentIdSchema as elenaAgentIdSchema2,
|
|
298
|
+
elenaAgentSecretSchema,
|
|
299
|
+
elenaAgentTimestampSchema
|
|
300
|
+
} from "@theelena/sdk/agent";
|
|
301
|
+
var CredentialSession = class {
|
|
302
|
+
#secret;
|
|
303
|
+
source;
|
|
304
|
+
baseUrl;
|
|
305
|
+
staffOrigin;
|
|
306
|
+
credentialId;
|
|
307
|
+
restaurantId;
|
|
308
|
+
expiresAt;
|
|
309
|
+
constructor(data, source, guard) {
|
|
310
|
+
guard.add(data.secret);
|
|
311
|
+
const secret = elenaAgentSecretSchema.safeParse(data.secret);
|
|
312
|
+
if (!secret.success) throw new CliError("CREDENTIAL_WRONG_FAMILY");
|
|
313
|
+
this.#secret = secret.data;
|
|
314
|
+
this.source = source;
|
|
315
|
+
this.baseUrl = origin(data.baseUrl);
|
|
316
|
+
this.staffOrigin = data.staffOrigin ? origin(data.staffOrigin) : void 0;
|
|
317
|
+
this.credentialId = data.credentialId ?? null;
|
|
318
|
+
this.restaurantId = data.restaurantId ?? null;
|
|
319
|
+
this.expiresAt = data.expiresAt ?? null;
|
|
320
|
+
}
|
|
321
|
+
client(factory = createElenaAgentClient) {
|
|
322
|
+
return factory({
|
|
323
|
+
baseUrl: this.baseUrl,
|
|
324
|
+
agentKey: this.#secret,
|
|
325
|
+
...this.staffOrigin ? { staffOrigin: this.staffOrigin } : {}
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
function missing(error) {
|
|
330
|
+
return !!error && typeof error === "object" && "code" in error && error.code === "ENOENT";
|
|
331
|
+
}
|
|
332
|
+
async function rejectSymlinks(path) {
|
|
333
|
+
const absolute = resolve(path);
|
|
334
|
+
const root = parsePath(absolute).root;
|
|
335
|
+
let cursor = root;
|
|
336
|
+
for (const component of absolute.slice(root.length).split("/").filter(Boolean)) {
|
|
337
|
+
cursor = join(cursor, component);
|
|
338
|
+
try {
|
|
339
|
+
if ((await lstat(cursor)).isSymbolicLink()) throw new CliError("INVALID_REQUEST");
|
|
340
|
+
} catch (error) {
|
|
341
|
+
if (missing(error)) return;
|
|
342
|
+
throw error;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
function checkPermissions(info, directory) {
|
|
347
|
+
const mode = directory ? 448 : 384;
|
|
348
|
+
if ((info.mode & 4095) !== mode || process.getuid && info.uid !== process.getuid() || (directory ? !info.isDirectory() : !info.isFile() || info.nlink !== 1)) {
|
|
349
|
+
throw new CliError("INVALID_REQUEST");
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
function profile(value) {
|
|
353
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
354
|
+
throw new CliError("INVALID_REQUEST");
|
|
355
|
+
const fields = Object.keys(value).sort().join(",");
|
|
356
|
+
if (fields !== "baseUrl,credentialId,expiresAt,restaurantId,secret,staffOrigin,version")
|
|
357
|
+
throw new CliError("INVALID_REQUEST");
|
|
358
|
+
const data = value;
|
|
359
|
+
if (data.version !== "1") throw new CliError("INVALID_REQUEST");
|
|
360
|
+
return {
|
|
361
|
+
version: "1",
|
|
362
|
+
baseUrl: origin(data.baseUrl),
|
|
363
|
+
staffOrigin: origin(data.staffOrigin),
|
|
364
|
+
secret: parse(elenaAgentSecretSchema, data.secret),
|
|
365
|
+
credentialId: parse(elenaAgentIdSchema2, data.credentialId),
|
|
366
|
+
restaurantId: parse(elenaAgentIdSchema2, data.restaurantId),
|
|
367
|
+
expiresAt: parse(elenaAgentTimestampSchema, data.expiresAt)
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
var CredentialStore = class {
|
|
371
|
+
directory;
|
|
372
|
+
constructor(directory = join(homedir(), ".config", "elena-catalog")) {
|
|
373
|
+
this.directory = resolve(directory);
|
|
374
|
+
}
|
|
375
|
+
async #directory(create) {
|
|
376
|
+
await rejectSymlinks(this.directory);
|
|
377
|
+
try {
|
|
378
|
+
checkPermissions(await lstat(this.directory), true);
|
|
379
|
+
} catch (error) {
|
|
380
|
+
if (!missing(error)) throw error;
|
|
381
|
+
if (!create) return false;
|
|
382
|
+
await mkdir(this.directory, { recursive: true, mode: 448 });
|
|
383
|
+
await rejectSymlinks(this.directory);
|
|
384
|
+
checkPermissions(await lstat(this.directory), true);
|
|
385
|
+
}
|
|
386
|
+
return true;
|
|
387
|
+
}
|
|
388
|
+
async load(guard) {
|
|
389
|
+
if (!await this.#directory(false)) return null;
|
|
390
|
+
const path = join(this.directory, "credentials.json");
|
|
391
|
+
await rejectSymlinks(path);
|
|
392
|
+
let file;
|
|
393
|
+
try {
|
|
394
|
+
file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
395
|
+
} catch (error) {
|
|
396
|
+
if (missing(error)) return null;
|
|
397
|
+
throw new CliError("INVALID_REQUEST");
|
|
398
|
+
}
|
|
399
|
+
try {
|
|
400
|
+
const info = await file.stat();
|
|
401
|
+
checkPermissions(info, false);
|
|
402
|
+
if (info.size > 8192) throw new CliError("INVALID_REQUEST");
|
|
403
|
+
let value;
|
|
404
|
+
try {
|
|
405
|
+
value = JSON.parse(await file.readFile("utf8"));
|
|
406
|
+
} catch {
|
|
407
|
+
throw new CliError("INVALID_REQUEST");
|
|
408
|
+
}
|
|
409
|
+
const data = profile(value);
|
|
410
|
+
return new CredentialSession(data, "store", guard);
|
|
411
|
+
} finally {
|
|
412
|
+
await file.close();
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
async save(input) {
|
|
416
|
+
const data = profile(input);
|
|
417
|
+
await this.#directory(true);
|
|
418
|
+
const path = join(this.directory, "credentials.json");
|
|
419
|
+
await rejectSymlinks(path);
|
|
420
|
+
try {
|
|
421
|
+
checkPermissions(await lstat(path), false);
|
|
422
|
+
} catch (error) {
|
|
423
|
+
if (!missing(error)) throw error;
|
|
424
|
+
}
|
|
425
|
+
const temporary = join(this.directory, `.credentials-${randomUUID()}`);
|
|
426
|
+
const file = await open(
|
|
427
|
+
temporary,
|
|
428
|
+
constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW,
|
|
429
|
+
384
|
|
430
|
+
);
|
|
431
|
+
try {
|
|
432
|
+
try {
|
|
433
|
+
await file.writeFile(JSON.stringify(data), "utf8");
|
|
434
|
+
await file.sync();
|
|
435
|
+
} finally {
|
|
436
|
+
await file.close();
|
|
437
|
+
}
|
|
438
|
+
await this.#directory(false);
|
|
439
|
+
await rejectSymlinks(path);
|
|
440
|
+
await rename(temporary, path);
|
|
441
|
+
const directory = await open(
|
|
442
|
+
this.directory,
|
|
443
|
+
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW
|
|
444
|
+
);
|
|
445
|
+
try {
|
|
446
|
+
await directory.sync();
|
|
447
|
+
} finally {
|
|
448
|
+
await directory.close();
|
|
449
|
+
}
|
|
450
|
+
} finally {
|
|
451
|
+
try {
|
|
452
|
+
await unlink(temporary);
|
|
453
|
+
} catch (error) {
|
|
454
|
+
if (!missing(error)) throw error;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
async remove() {
|
|
459
|
+
if (!await this.#directory(false)) return;
|
|
460
|
+
const path = join(this.directory, "credentials.json");
|
|
461
|
+
await rejectSymlinks(path);
|
|
462
|
+
try {
|
|
463
|
+
checkPermissions(await lstat(path), false);
|
|
464
|
+
await unlink(path);
|
|
465
|
+
} catch (error) {
|
|
466
|
+
if (!missing(error)) throw error;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
async function resolveSession(store, env, guard) {
|
|
471
|
+
const key = env.ELENA_AGENT_KEY;
|
|
472
|
+
const baseUrl = env.ELENA_AGENT_BASE_URL;
|
|
473
|
+
guard.add(key);
|
|
474
|
+
if (key !== void 0 !== (baseUrl !== void 0)) throw new CliError("INVALID_REQUEST");
|
|
475
|
+
const stored = await store.load(guard);
|
|
476
|
+
if (key !== void 0 && baseUrl !== void 0) {
|
|
477
|
+
const base = origin(baseUrl);
|
|
478
|
+
if (stored && stored.baseUrl !== base) throw new CliError("ORIGIN_REJECTED");
|
|
479
|
+
const staffOrigin = env.ELENA_AGENT_STAFF_ORIGIN ? origin(env.ELENA_AGENT_STAFF_ORIGIN) : stored?.staffOrigin;
|
|
480
|
+
if (stored?.staffOrigin && staffOrigin !== stored.staffOrigin)
|
|
481
|
+
throw new CliError("ORIGIN_REJECTED");
|
|
482
|
+
return new CredentialSession({ secret: key, baseUrl: base, staffOrigin }, "environment", guard);
|
|
483
|
+
}
|
|
484
|
+
if (stored && env.ELENA_AGENT_STAFF_ORIGIN && origin(env.ELENA_AGENT_STAFF_ORIGIN) !== stored.staffOrigin)
|
|
485
|
+
throw new CliError("ORIGIN_REJECTED");
|
|
486
|
+
return stored;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// src/auth.ts
|
|
490
|
+
async function pairingRequest(runtime, baseUrl, endpoint, body, schema) {
|
|
491
|
+
let response;
|
|
492
|
+
try {
|
|
493
|
+
response = await runtime.fetch(`${baseUrl}/api/v1/agent-access/pairing/${endpoint}`, {
|
|
494
|
+
method: "POST",
|
|
495
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
496
|
+
body: JSON.stringify(body),
|
|
497
|
+
redirect: "error",
|
|
498
|
+
credentials: "omit",
|
|
499
|
+
cache: "no-store",
|
|
500
|
+
signal: AbortSignal.timeout(3e4)
|
|
501
|
+
});
|
|
502
|
+
} catch {
|
|
503
|
+
throw new CliError("TEMPORARY_UNAVAILABLE");
|
|
504
|
+
}
|
|
505
|
+
if (response.redirected || response.status >= 300 && response.status < 400)
|
|
506
|
+
throw new CliError("ORIGIN_REJECTED");
|
|
507
|
+
if (response.url && new URL(response.url).origin !== baseUrl)
|
|
508
|
+
throw new CliError("ORIGIN_REJECTED");
|
|
509
|
+
let value;
|
|
510
|
+
try {
|
|
511
|
+
if (!response.body || Number(response.headers.get("content-length") ?? 0) > 1024 * 1024)
|
|
512
|
+
throw new Error();
|
|
513
|
+
const reader = response.body.getReader();
|
|
514
|
+
const chunks = [];
|
|
515
|
+
let size = 0;
|
|
516
|
+
try {
|
|
517
|
+
while (true) {
|
|
518
|
+
const chunk = await reader.read();
|
|
519
|
+
if (chunk.done) break;
|
|
520
|
+
size += chunk.value.byteLength;
|
|
521
|
+
if (size > 1024 * 1024) {
|
|
522
|
+
await reader.cancel();
|
|
523
|
+
throw new Error();
|
|
524
|
+
}
|
|
525
|
+
chunks.push(chunk.value);
|
|
526
|
+
}
|
|
527
|
+
} finally {
|
|
528
|
+
reader.releaseLock();
|
|
529
|
+
}
|
|
530
|
+
value = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
531
|
+
} catch {
|
|
532
|
+
throw new CliError("TEMPORARY_UNAVAILABLE");
|
|
533
|
+
}
|
|
534
|
+
if (!response.ok) {
|
|
535
|
+
const error = elenaAgentErrorSchema.safeParse(value);
|
|
536
|
+
if (!error.success) throw new CliError("TEMPORARY_UNAVAILABLE");
|
|
537
|
+
const retryHeader = response.headers.get("retry-after");
|
|
538
|
+
const headerSeconds = retryHeader ? /^\d+$/.test(retryHeader) ? Number(retryHeader) : Math.max(1, Math.ceil((Date.parse(retryHeader) - runtime.now()) / 1e3)) : 0;
|
|
539
|
+
const retry = Math.max(error.data.error.retryAfterSeconds ?? 0, headerSeconds);
|
|
540
|
+
if (!Number.isFinite(retry) || retry > 3600) throw new CliError(error.data.error.code);
|
|
541
|
+
throw new PairingServerError(error.data.error.code, retry || void 0);
|
|
542
|
+
}
|
|
543
|
+
if (response.status !== (endpoint === "start" ? 201 : 200)) throw new CliError("INVALID_REQUEST");
|
|
544
|
+
return parse(schema, value);
|
|
545
|
+
}
|
|
546
|
+
var PairingServerError = class extends CliError {
|
|
547
|
+
};
|
|
548
|
+
async function login(options) {
|
|
549
|
+
const { store, guard, runtime } = options;
|
|
550
|
+
const baseUrl = origin(options.baseUrl);
|
|
551
|
+
const staffOrigin = origin(options.staffOrigin);
|
|
552
|
+
const previous = await store.load(guard);
|
|
553
|
+
if (previous && (previous.baseUrl !== baseUrl || previous.staffOrigin !== staffOrigin))
|
|
554
|
+
throw new CliError("ORIGIN_REJECTED");
|
|
555
|
+
let retries = 0;
|
|
556
|
+
let start;
|
|
557
|
+
for (; ; ) {
|
|
558
|
+
try {
|
|
559
|
+
start = await pairingRequest(
|
|
560
|
+
runtime,
|
|
561
|
+
baseUrl,
|
|
562
|
+
"start",
|
|
563
|
+
{ version: "1", label: "elena-catalog" },
|
|
564
|
+
elenaAgentPairingStartResponseSchema
|
|
565
|
+
);
|
|
566
|
+
break;
|
|
567
|
+
} catch (error) {
|
|
568
|
+
if (!(error instanceof PairingServerError) || !["RATE_LIMITED", "TEMPORARY_UNAVAILABLE"].includes(error.code) || retries++ >= 2)
|
|
569
|
+
throw error;
|
|
570
|
+
await runtime.sleep(Math.max(5, error.retryAfterSeconds ?? 5) * 1e3);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
guard.add(start.deviceCode);
|
|
574
|
+
const verificationUrl = pinUrl(start.verificationUrl, staffOrigin);
|
|
575
|
+
guard.assertSafe(verificationUrl);
|
|
576
|
+
guard.assertSafe(start.userCode);
|
|
577
|
+
runtime.notify(
|
|
578
|
+
`Abra ${verificationUrl}
|
|
579
|
+
C\xF3digo: ${start.userCode}
|
|
580
|
+
La aprobaci\xF3n no ejecuta cambios. No comparta secretos.`
|
|
581
|
+
);
|
|
582
|
+
let expires = Math.min(Date.parse(start.expiresAt), runtime.now() + 6e5);
|
|
583
|
+
let delay = start.intervalSeconds;
|
|
584
|
+
retries = 0;
|
|
585
|
+
for (; ; ) {
|
|
586
|
+
if (runtime.now() + delay * 1e3 >= expires) throw new CliError("PAIRING_EXPIRED");
|
|
587
|
+
await runtime.sleep(delay * 1e3);
|
|
588
|
+
if (runtime.now() >= expires) throw new CliError("PAIRING_EXPIRED");
|
|
589
|
+
try {
|
|
590
|
+
const result = await pairingRequest(
|
|
591
|
+
runtime,
|
|
592
|
+
baseUrl,
|
|
593
|
+
"poll",
|
|
594
|
+
{ version: "1", deviceCode: start.deviceCode },
|
|
595
|
+
elenaAgentPairingPollResponseSchema
|
|
596
|
+
);
|
|
597
|
+
if (result.status === "pending") {
|
|
598
|
+
expires = Math.min(expires, Date.parse(result.expiresAt));
|
|
599
|
+
delay = Math.max(5, result.intervalSeconds);
|
|
600
|
+
retries = 0;
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
guard.add(result.credential.secret);
|
|
604
|
+
if (result.credential.key.status !== "active" || Date.parse(result.credential.key.expiresAt) <= runtime.now())
|
|
605
|
+
throw new CliError("CREDENTIAL_EXPIRED");
|
|
606
|
+
try {
|
|
607
|
+
await store.save({
|
|
608
|
+
version: "1",
|
|
609
|
+
baseUrl,
|
|
610
|
+
staffOrigin,
|
|
611
|
+
secret: result.credential.secret,
|
|
612
|
+
credentialId: result.credential.key.id,
|
|
613
|
+
restaurantId: result.restaurant.id,
|
|
614
|
+
expiresAt: result.credential.key.expiresAt
|
|
615
|
+
});
|
|
616
|
+
} catch {
|
|
617
|
+
runtime.notify(
|
|
618
|
+
"No se pudo guardar la entrega \xFAnica. Solicite al due\xF1o revocar esta conexi\xF3n por su etiqueta antes de iniciar otra."
|
|
619
|
+
);
|
|
620
|
+
throw new CliError("INVALID_REQUEST");
|
|
621
|
+
}
|
|
622
|
+
return {
|
|
623
|
+
version: "1",
|
|
624
|
+
authenticated: true,
|
|
625
|
+
source: "store",
|
|
626
|
+
baseUrl,
|
|
627
|
+
credentialId: result.credential.key.id,
|
|
628
|
+
restaurantId: result.restaurant.id,
|
|
629
|
+
expiresAt: result.credential.key.expiresAt
|
|
630
|
+
};
|
|
631
|
+
} catch (error) {
|
|
632
|
+
if (error instanceof CliError && !(error instanceof PairingServerError) && error.code === "TEMPORARY_UNAVAILABLE") {
|
|
633
|
+
runtime.notify(
|
|
634
|
+
"La entrega \xFAnica puede haberse consumido. No vuelva a consultar ese c\xF3digo: solicite al due\xF1o revocar la conexi\xF3n hu\xE9rfana por su etiqueta e inicie otra conexi\xF3n."
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
if (!(error instanceof PairingServerError) || !["RATE_LIMITED", "TEMPORARY_UNAVAILABLE"].includes(error.code) || retries++ >= 2)
|
|
638
|
+
throw error;
|
|
639
|
+
delay = Math.max(5, error.retryAfterSeconds ?? 5);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
async function authStatus(session, factory, now) {
|
|
644
|
+
if (!session)
|
|
645
|
+
return {
|
|
646
|
+
version: "1",
|
|
647
|
+
authenticated: false,
|
|
648
|
+
source: "none",
|
|
649
|
+
baseUrl: null,
|
|
650
|
+
credentialId: null,
|
|
651
|
+
restaurantId: null,
|
|
652
|
+
expiresAt: null
|
|
653
|
+
};
|
|
654
|
+
if (session.expiresAt && Date.parse(session.expiresAt) <= now) {
|
|
655
|
+
return {
|
|
656
|
+
version: "1",
|
|
657
|
+
authenticated: false,
|
|
658
|
+
source: session.source,
|
|
659
|
+
baseUrl: session.baseUrl,
|
|
660
|
+
credentialId: null,
|
|
661
|
+
restaurantId: null,
|
|
662
|
+
expiresAt: null
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
try {
|
|
666
|
+
const context = await session.client(factory).context.get();
|
|
667
|
+
if (session.credentialId && context.principal.credentialId !== session.credentialId || session.restaurantId && context.restaurant.id !== session.restaurantId)
|
|
668
|
+
throw new CliError("CREDENTIAL_INVALID");
|
|
669
|
+
return {
|
|
670
|
+
version: "1",
|
|
671
|
+
authenticated: Date.parse(context.principal.expiresAt) > now,
|
|
672
|
+
source: session.source,
|
|
673
|
+
baseUrl: session.baseUrl,
|
|
674
|
+
credentialId: context.principal.credentialId,
|
|
675
|
+
restaurantId: context.restaurant.id,
|
|
676
|
+
expiresAt: context.principal.expiresAt
|
|
677
|
+
};
|
|
678
|
+
} catch (error) {
|
|
679
|
+
if (error && typeof error === "object" && "code" in error && ["CREDENTIAL_INVALID", "CREDENTIAL_EXPIRED", "CREDENTIAL_REVOKED"].includes(
|
|
680
|
+
String(error.code)
|
|
681
|
+
)) {
|
|
682
|
+
return {
|
|
683
|
+
version: "1",
|
|
684
|
+
authenticated: false,
|
|
685
|
+
source: session.source,
|
|
686
|
+
baseUrl: session.baseUrl,
|
|
687
|
+
credentialId: null,
|
|
688
|
+
restaurantId: null,
|
|
689
|
+
expiresAt: null
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
throw error;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// src/commands.ts
|
|
697
|
+
import "@theelena/sdk/agent";
|
|
698
|
+
async function readChanges(client, changes) {
|
|
699
|
+
const read = /* @__PURE__ */ new Set();
|
|
700
|
+
const readTarget = async (kind, id, revision) => {
|
|
701
|
+
const cacheKey = `${kind}:${id}:${revision}`;
|
|
702
|
+
if (read.has(cacheKey)) return;
|
|
703
|
+
if (kind === "product") {
|
|
704
|
+
const { product } = await client.catalog.products.get(id);
|
|
705
|
+
if (product.revision !== revision) throw new CliError("VERSION_CONFLICT");
|
|
706
|
+
if (product.isDraft) throw new CliError("REFERENCE_CONFLICT");
|
|
707
|
+
} else {
|
|
708
|
+
const { category } = await client.catalog.categories.get(id);
|
|
709
|
+
if (category.revision !== revision) throw new CliError("VERSION_CONFLICT");
|
|
710
|
+
}
|
|
711
|
+
read.add(cacheKey);
|
|
712
|
+
};
|
|
713
|
+
for (const change of changes) {
|
|
714
|
+
const kind = change.kind === "product.upsert" ? "product" : "category";
|
|
715
|
+
if (change.target.kind === "id") {
|
|
716
|
+
await readTarget(kind, change.target.id, change.target.expectedVersion);
|
|
717
|
+
} else if (change.target.expectedVersion !== null && !read.has(`${kind}:external`)) {
|
|
718
|
+
const cursors = /* @__PURE__ */ new Set();
|
|
719
|
+
let cursor;
|
|
720
|
+
do {
|
|
721
|
+
const query = { version: "1", limit: 100, ...cursor ? { cursor } : {} };
|
|
722
|
+
const page = kind === "product" ? await client.catalog.products.list(query) : await client.catalog.categories.list(query);
|
|
723
|
+
cursor = page.nextCursor ?? void 0;
|
|
724
|
+
if (cursor && cursors.has(cursor)) throw new CliError("INVALID_REQUEST");
|
|
725
|
+
if (cursor) cursors.add(cursor);
|
|
726
|
+
} while (cursor);
|
|
727
|
+
read.add(`${kind}:external`);
|
|
728
|
+
}
|
|
729
|
+
if (change.kind === "product.upsert" && change.data.category.kind === "id") {
|
|
730
|
+
await readTarget("category", change.data.category.id, change.data.category.expectedVersion);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
async function proposalReread(client, request) {
|
|
735
|
+
const payload = request.payload;
|
|
736
|
+
if (payload.kind === "catalog") {
|
|
737
|
+
await readChanges(client, payload.changes);
|
|
738
|
+
} else if (payload.kind === "publication") {
|
|
739
|
+
for (const target of payload.products) {
|
|
740
|
+
const { product } = await client.catalog.products.get(target.id);
|
|
741
|
+
if (product.revision !== target.expectedVersion) throw new CliError("VERSION_CONFLICT");
|
|
742
|
+
if (product.isDraft) throw new CliError("REFERENCE_CONFLICT");
|
|
743
|
+
}
|
|
744
|
+
} else {
|
|
745
|
+
const preview = await client.imports.get(payload.importId);
|
|
746
|
+
if (preview.planHash !== payload.planHash) throw new CliError("VERSION_CONFLICT");
|
|
747
|
+
for (const ref of payload.selectedRefs) {
|
|
748
|
+
const row = preview.items.find((item) => item.ref === ref);
|
|
749
|
+
if (!row || row.classification === "invalid" || row.classification === "conflict")
|
|
750
|
+
throw new CliError("REFERENCE_CONFLICT");
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
async function wireCommand(client, parsed) {
|
|
755
|
+
const { command, args, flags } = parsed;
|
|
756
|
+
switch (command) {
|
|
757
|
+
case "context":
|
|
758
|
+
return client.context.get();
|
|
759
|
+
case "catalog categories list":
|
|
760
|
+
return client.catalog.categories.list(pageQuery(flags));
|
|
761
|
+
case "catalog categories get":
|
|
762
|
+
return client.catalog.categories.get(args[0]);
|
|
763
|
+
case "catalog products list":
|
|
764
|
+
return client.catalog.products.list(pageQuery(flags));
|
|
765
|
+
case "catalog products get":
|
|
766
|
+
return client.catalog.products.get(args[0]);
|
|
767
|
+
case "assets get":
|
|
768
|
+
return client.assets.get(args[0]);
|
|
769
|
+
case "media status":
|
|
770
|
+
return client.media.get(args[0]);
|
|
771
|
+
case "import status":
|
|
772
|
+
return client.imports.get(args[0]);
|
|
773
|
+
case "operations list":
|
|
774
|
+
return client.operations.list(pageQuery(flags));
|
|
775
|
+
case "operations status":
|
|
776
|
+
return client.operations.get(args[0]);
|
|
777
|
+
case "operations apply":
|
|
778
|
+
return client.operations.apply(args[0], applyRequest(flags));
|
|
779
|
+
default:
|
|
780
|
+
throw new CliError("INVALID_REQUEST");
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// src/files.ts
|
|
785
|
+
import { constants as constants2 } from "fs";
|
|
786
|
+
import { open as open2 } from "fs/promises";
|
|
787
|
+
import { basename, extname } from "path";
|
|
788
|
+
import { createHash } from "crypto";
|
|
789
|
+
import { ELENA_AGENT_LIMITS, elenaAgentMediaStageRequestSchema } from "@theelena/sdk/agent";
|
|
790
|
+
async function localBytes(path, max) {
|
|
791
|
+
if (!path || /^[a-z][a-z\d+.-]*:/i.test(path) || path.includes("\0"))
|
|
792
|
+
throw new CliError("INVALID_REQUEST");
|
|
793
|
+
let file;
|
|
794
|
+
try {
|
|
795
|
+
file = await open2(path, constants2.O_RDONLY | constants2.O_NOFOLLOW | constants2.O_NONBLOCK);
|
|
796
|
+
} catch {
|
|
797
|
+
throw new CliError("INVALID_REQUEST");
|
|
798
|
+
}
|
|
799
|
+
try {
|
|
800
|
+
const info = await file.stat();
|
|
801
|
+
if (!info.isFile() || info.size < 1 || info.size > max) throw new CliError("INVALID_REQUEST");
|
|
802
|
+
const bytes = Buffer.alloc(Math.min(info.size + 1, max + 1));
|
|
803
|
+
let count = 0;
|
|
804
|
+
while (count < bytes.length) {
|
|
805
|
+
const result = await file.read(bytes, count, bytes.length - count, count);
|
|
806
|
+
if (result.bytesRead === 0) break;
|
|
807
|
+
count += result.bytesRead;
|
|
808
|
+
}
|
|
809
|
+
if (count !== info.size) throw new CliError("INVALID_REQUEST");
|
|
810
|
+
return bytes.subarray(0, count);
|
|
811
|
+
} finally {
|
|
812
|
+
await file.close();
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
async function requestFile(path, schema, guard) {
|
|
816
|
+
const bytes = await localBytes(path, ELENA_AGENT_LIMITS.jsonBytes);
|
|
817
|
+
let value;
|
|
818
|
+
try {
|
|
819
|
+
value = JSON.parse(bytes.toString("utf8"));
|
|
820
|
+
} catch {
|
|
821
|
+
throw new CliError("INVALID_REQUEST");
|
|
822
|
+
}
|
|
823
|
+
guard.assertSafe(value);
|
|
824
|
+
return parse(schema, value);
|
|
825
|
+
}
|
|
826
|
+
async function stagedFile(path, idempotencyKey, guard) {
|
|
827
|
+
const bytes = await localBytes(path, ELENA_AGENT_LIMITS.mediaBytes);
|
|
828
|
+
const extension = extname(path).toLowerCase();
|
|
829
|
+
let mimeType;
|
|
830
|
+
if (bytes.length >= 24 && bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) && extension === ".png") {
|
|
831
|
+
mimeType = "image/png";
|
|
832
|
+
const width = bytes.readUInt32BE(16);
|
|
833
|
+
const height = bytes.readUInt32BE(20);
|
|
834
|
+
if (!width || !height || width > 1e4 || height > 1e4 || width * height > 4e7 || bytes.includes(Buffer.from("acTL")))
|
|
835
|
+
throw new CliError("MEDIA_INVALID");
|
|
836
|
+
} else if (bytes.length >= 4 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255 && [".jpg", ".jpeg"].includes(extension)) {
|
|
837
|
+
mimeType = "image/jpeg";
|
|
838
|
+
} else if (bytes.length >= 20 && bytes.toString("ascii", 0, 4) === "RIFF" && bytes.toString("ascii", 8, 12) === "WEBP" && extension === ".webp") {
|
|
839
|
+
mimeType = "image/webp";
|
|
840
|
+
if (bytes.readUInt32LE(4) + 8 !== bytes.length || bytes.includes(Buffer.from("ANIM")) || bytes.toString("ascii", 12, 16) === "VP8X" && (bytes[20] & 2) !== 0)
|
|
841
|
+
throw new CliError("MEDIA_INVALID");
|
|
842
|
+
} else {
|
|
843
|
+
throw new CliError("MEDIA_INVALID");
|
|
844
|
+
}
|
|
845
|
+
const metadata = parse(elenaAgentMediaStageRequestSchema, {
|
|
846
|
+
version: "1",
|
|
847
|
+
idempotencyKey,
|
|
848
|
+
fileName: basename(path),
|
|
849
|
+
mimeType,
|
|
850
|
+
bytes: bytes.length,
|
|
851
|
+
sha256: createHash("sha256").update(bytes).digest("hex")
|
|
852
|
+
});
|
|
853
|
+
guard.assertSafe(metadata);
|
|
854
|
+
return { metadata, file: new Blob([new Uint8Array(bytes)], { type: mimeType }) };
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// src/cli.ts
|
|
858
|
+
async function runCli(argv, overrides = {}) {
|
|
859
|
+
const runtime = {
|
|
860
|
+
env: process.env,
|
|
861
|
+
store: new CredentialStore(),
|
|
862
|
+
createClient: createElenaAgentClient2,
|
|
863
|
+
fetch: globalThis.fetch,
|
|
864
|
+
stdout: (text) => {
|
|
865
|
+
process.stdout.write(text);
|
|
866
|
+
},
|
|
867
|
+
stderr: (text) => {
|
|
868
|
+
process.stderr.write(text);
|
|
869
|
+
},
|
|
870
|
+
sleep: (milliseconds) => sleep(milliseconds),
|
|
871
|
+
now: () => Date.now(),
|
|
872
|
+
...overrides
|
|
873
|
+
};
|
|
874
|
+
const guard = new SecretGuard();
|
|
875
|
+
guard.add(runtime.env.ELENA_AGENT_KEY);
|
|
876
|
+
const print = (value) => {
|
|
877
|
+
guard.assertSafe(value);
|
|
878
|
+
runtime.stdout(`${JSON.stringify(value)}
|
|
879
|
+
`);
|
|
880
|
+
};
|
|
881
|
+
try {
|
|
882
|
+
const parsed = argumentsFor(argv, guard);
|
|
883
|
+
if (parsed.command === "help") {
|
|
884
|
+
runtime.stdout(HELP);
|
|
885
|
+
return 0;
|
|
886
|
+
}
|
|
887
|
+
if (parsed.command === "version") {
|
|
888
|
+
runtime.stdout("0.1.0\n");
|
|
889
|
+
return 0;
|
|
890
|
+
}
|
|
891
|
+
if (parsed.command === "auth logout") {
|
|
892
|
+
await runtime.store.remove();
|
|
893
|
+
print({
|
|
894
|
+
version: "1",
|
|
895
|
+
loggedOut: true,
|
|
896
|
+
remoteRevoked: false,
|
|
897
|
+
environmentCredentialPresent: runtime.env.ELENA_AGENT_KEY !== void 0
|
|
898
|
+
});
|
|
899
|
+
return 0;
|
|
900
|
+
}
|
|
901
|
+
if (parsed.command === "auth login") {
|
|
902
|
+
if (runtime.env.ELENA_AGENT_KEY !== void 0 || runtime.env.ELENA_AGENT_BASE_URL !== void 0)
|
|
903
|
+
throw new CliError("INVALID_REQUEST");
|
|
904
|
+
const baseUrl = origin(parsed.flags["--base-url"]);
|
|
905
|
+
const staffOrigin = origin(runtime.env.ELENA_AGENT_STAFF_ORIGIN);
|
|
906
|
+
const result2 = await login({
|
|
907
|
+
baseUrl,
|
|
908
|
+
staffOrigin,
|
|
909
|
+
store: runtime.store,
|
|
910
|
+
guard,
|
|
911
|
+
runtime: {
|
|
912
|
+
fetch: runtime.fetch,
|
|
913
|
+
sleep: runtime.sleep,
|
|
914
|
+
now: runtime.now,
|
|
915
|
+
notify: (message) => {
|
|
916
|
+
guard.assertSafe(message);
|
|
917
|
+
runtime.stderr(`${message}
|
|
918
|
+
`);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
});
|
|
922
|
+
print(result2);
|
|
923
|
+
return 0;
|
|
924
|
+
}
|
|
925
|
+
const preview = parsed.command === "import preview" ? await requestFile(parsed.flags["--file"], elenaAgentImportPreviewRequestSchema, guard) : void 0;
|
|
926
|
+
const proposal = parsed.command === "operations propose" ? await requestFile(parsed.flags["--file"], elenaAgentProposalRequestSchema, guard) : void 0;
|
|
927
|
+
const media = parsed.command === "media stage" ? await stagedFile(parsed.args[0], parsed.flags["--idempotency-key"], guard) : void 0;
|
|
928
|
+
const session = await resolveSession(runtime.store, runtime.env, guard);
|
|
929
|
+
if (parsed.command === "auth status") {
|
|
930
|
+
const status = await authStatus(session, runtime.createClient, runtime.now());
|
|
931
|
+
print(status);
|
|
932
|
+
return session && !status.authenticated ? 3 : 0;
|
|
933
|
+
}
|
|
934
|
+
if (!session) throw new CliError("CREDENTIAL_REQUIRED");
|
|
935
|
+
if (session.expiresAt && Date.parse(session.expiresAt) <= runtime.now())
|
|
936
|
+
throw new CliError("CREDENTIAL_EXPIRED");
|
|
937
|
+
if (parsed.command.startsWith("operations ") && !session.staffOrigin)
|
|
938
|
+
throw new CliError("INVALID_REQUEST");
|
|
939
|
+
const client = session.client(runtime.createClient);
|
|
940
|
+
const context = await client.context.get();
|
|
941
|
+
if (Date.parse(context.principal.expiresAt) <= runtime.now())
|
|
942
|
+
throw new CliError("CREDENTIAL_EXPIRED");
|
|
943
|
+
if (session.restaurantId && context.restaurant.id !== session.restaurantId || session.credentialId && context.principal.credentialId !== session.credentialId)
|
|
944
|
+
throw new CliError("CREDENTIAL_INVALID");
|
|
945
|
+
let result;
|
|
946
|
+
if (parsed.command === "context") result = context;
|
|
947
|
+
else if (preview) result = await client.imports.preview(preview);
|
|
948
|
+
else if (proposal) {
|
|
949
|
+
await proposalReread(client, proposal);
|
|
950
|
+
result = await client.operations.propose(proposal);
|
|
951
|
+
} else if (media) result = await client.media.stage(media.metadata, media.file);
|
|
952
|
+
else result = await wireCommand(client, parsed);
|
|
953
|
+
if (result && typeof result === "object" && "approvalUrl" in result) {
|
|
954
|
+
if (!session.staffOrigin || typeof result.approvalUrl !== "string")
|
|
955
|
+
throw new CliError("ORIGIN_REJECTED");
|
|
956
|
+
pinUrl(result.approvalUrl, session.staffOrigin);
|
|
957
|
+
}
|
|
958
|
+
print(result);
|
|
959
|
+
if (result && typeof result === "object" && "state" in result && ["partial", "failed", "rejected", "expired", "cancelled"].includes(String(result.state)))
|
|
960
|
+
return 4;
|
|
961
|
+
return 0;
|
|
962
|
+
} catch (error) {
|
|
963
|
+
const safe = safeError(error);
|
|
964
|
+
runtime.stderr(`${JSON.stringify(safe.toWire())}
|
|
965
|
+
`);
|
|
966
|
+
return exitFor(safe.code);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// src/index.ts
|
|
971
|
+
process.exitCode = await runCli(process.argv.slice(2));
|
package/package.json
CHANGED
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@theelena/catalog-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "CLI de catálogo de Elena con autorización humana y credenciales de restaurante",
|
|
5
|
+
"author": "Elena Tech",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"publishConfig": {
|
|
9
|
+
"access": "public",
|
|
10
|
+
"registry": "https://registry.npmjs.org/"
|
|
11
|
+
},
|
|
12
|
+
"bin": {
|
|
13
|
+
"elena-catalog": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"package.json"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=20"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsup",
|
|
24
|
+
"typecheck": "tsc --noEmit",
|
|
25
|
+
"test": "vitest run --maxWorkers=2"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@theelena/sdk": "0.1.2"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/node": "^22",
|
|
32
|
+
"tsup": "^8.5.1",
|
|
33
|
+
"typescript": "^5",
|
|
34
|
+
"vitest": "^4.1.9"
|
|
35
|
+
}
|
|
36
|
+
}
|
package/README.md
DELETED