@timekast/factory 1.26.0 → 1.28.0

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.
@@ -0,0 +1,487 @@
1
+ /**
2
+ * `factory ticket push <archivo>` — the derivative → Factory channel.
3
+ *
4
+ * A derivative documents a kit defect as a markdown file under `project/factory/`
5
+ * (the `fx-factory-tickets` convention). Until now that file was a dead end: it sat
6
+ * in the repo until somebody opened the Factory by hand. This command delivers it as
7
+ * a GitHub issue in the Factory repo and stamps the resulting URL back into the
8
+ * ticket's canonical `**GitHub issue:**` slot.
9
+ *
10
+ * THREE THINGS THIS COMMAND DELIBERATELY DOES NOT DO:
11
+ *
12
+ * 1. It never CREATES the `kit-drift` label — only applies it. Creating a label
13
+ * requires `write` on the repo; applying one requires `triage`. Creating it
14
+ * would break the command for exactly the population that only has `triage`.
15
+ * A missing label degrades to a warning + a retry without it, never a failure.
16
+ * 2. It never spawns a shell. Every one of the CLI's `execa` calls is
17
+ * `execa(file, [args])` with no `shell: true`; a `... || true` would break that
18
+ * invariant and make the path and the body injectable. Tolerance comes from
19
+ * `{ reject: false }` and reading `exitCode`, not from shell syntax.
20
+ * 3. It never publishes without a gate, and it never prints the body. The ticket
21
+ * lands in a repo the whole org reads, so publishing asks first: `--dry-run`
22
+ * shows the destination and the derived title, and an interactive confirmation
23
+ * (fail-closed when headless) precedes the create. Keeping the body off the
24
+ * screen is not a secret heuristic — it is the observation that the file is in
25
+ * the caller's own repo and the published body is byte-identical to it, so
26
+ * opening the file IS reviewing what would be published.
27
+ *
28
+ * The body travels over STDIN (`--body-file -` + `{ input }`), the same discipline
29
+ * as `gh secret set` in `provision.ts`: it never lands in argv (visible in the
30
+ * process table of the multi-tenant Agent Server) and never hits the argv limit.
31
+ *
32
+ * CREDENTIALS: none new. The command reuses the developer's own `gh` session via
33
+ * the shared `runPreflight` (gh installed → authenticated → org member). A failed
34
+ * preflight degrades to a note + `exit 0` — the same `not-configured` contract
35
+ * `factory backlog push` has without `BACKLOG_API_KEY`, never a stack trace.
36
+ */
37
+ import { existsSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
38
+ import path from 'node:path';
39
+ import { execa } from 'execa';
40
+ import prompts from 'prompts';
41
+ import { CLIError, PreflightError } from '../lib/cli-error.js';
42
+ import { FACTORY_REPO, FACTORY_TICKET_ASSIGNEE, FACTORY_TICKET_DIR, FACTORY_TICKET_LABEL, GITHUB_ISSUE_BODY_MAX, GITHUB_ISSUE_TITLE_MAX, } from '../lib/constants.js';
43
+ import { runPreflight } from '../lib/preflight.js';
44
+ import { detectRepo } from '../lib/repo-detection.js';
45
+ /**
46
+ * Flags of `ticket push` that take a VALUE. Their following token belongs to the
47
+ * flag, so it can never be read as the ticket path (`--label kit-drift ticket.md`
48
+ * used to resolve the "file" to `kit-drift` and die with "no se encontró el archivo").
49
+ */
50
+ const VALUE_FLAGS = new Set(['--assignee', '--label']);
51
+ /** Read `--name value` / `--name=value`; a following flag is NOT a value. */
52
+ function readFlagValue(argv, name) {
53
+ const inline = argv.find((arg) => arg.startsWith(`--${name}=`));
54
+ if (inline)
55
+ return inline.slice(name.length + 3) || undefined;
56
+ const idx = argv.indexOf(`--${name}`);
57
+ if (idx < 0)
58
+ return undefined;
59
+ const next = argv[idx + 1];
60
+ return next && !next.startsWith('-') ? next : undefined;
61
+ }
62
+ /**
63
+ * First positional argument: the first token that is neither a flag NOR the value of
64
+ * a value-taking flag ({@link VALUE_FLAGS}). Same discipline as {@link readFlagValue},
65
+ * read from the other side.
66
+ */
67
+ export function firstPositional(argv) {
68
+ for (let i = 0; i < argv.length; i += 1) {
69
+ const arg = argv[i];
70
+ if (arg.startsWith('-')) {
71
+ const next = argv[i + 1];
72
+ if (VALUE_FLAGS.has(arg) && next !== undefined && !next.startsWith('-'))
73
+ i += 1;
74
+ continue;
75
+ }
76
+ return arg;
77
+ }
78
+ return undefined;
79
+ }
80
+ export function parseTicketPushFlags(argv) {
81
+ return {
82
+ dryRun: argv.includes('--dry-run'),
83
+ yes: argv.includes('--yes') || argv.includes('-y'),
84
+ assignee: readFlagValue(argv, 'assignee') ?? FACTORY_TICKET_ASSIGNEE,
85
+ label: readFlagValue(argv, 'label') ?? FACTORY_TICKET_LABEL,
86
+ };
87
+ }
88
+ // ─────────────────────────────────────────────────────────────────────────────
89
+ // Path containment
90
+ // ─────────────────────────────────────────────────────────────────────────────
91
+ /**
92
+ * Resolve `filePath` to a real, regular file CONTAINED in `project/factory/`.
93
+ *
94
+ * The containment is `realpath` + `path.relative`, NOT a string prefix, and that
95
+ * is the whole point of this function:
96
+ *
97
+ * - `startsWith('project/factory')` accepts `project/factory-backup/…`.
98
+ * - git versions symlinks, so a file with a perfectly legitimate ticket name can
99
+ * point at `~/.claude/.env` — the org rail (VERCEL_TOKEN, NEON_API_KEY,
100
+ * CLOUDFLARE_API_TOKEN, RESEND_API_KEY, BACKLOG_ADMIN_TOKEN). Publishing that
101
+ * to a shared repo is the failure this guard exists to prevent.
102
+ *
103
+ * Unlike `assertSafeTrackedPath` (whose input is a release manifest), this input
104
+ * comes from the user AND the file gets opened and published — string checks do
105
+ * not cut it.
106
+ *
107
+ * @throws {CLIError} outside the tickets dir, missing, or not a regular file.
108
+ */
109
+ export function resolveTicketPath(rootDir, filePath) {
110
+ const ticketsRoot = path.join(rootDir, ...FACTORY_TICKET_DIR.split('/'));
111
+ if (!existsSync(ticketsRoot)) {
112
+ throw new CLIError(`No existe \`${FACTORY_TICKET_DIR}/\` en \`${rootDir}\`.\n` +
113
+ 'Un factory-ticket vive ahí (convención `fx-factory-tickets`); no hay nada que entregar.');
114
+ }
115
+ // A relative path is resolved against the CWD first (what a dev types after a
116
+ // tab-completion in a subdirectory) and against the repo root second (the
117
+ // canonical repo-relative form the docs use). Both must survive containment.
118
+ const candidates = path.isAbsolute(filePath)
119
+ ? [filePath]
120
+ : [path.resolve(process.cwd(), filePath), path.resolve(rootDir, filePath)];
121
+ const found = candidates.find((candidate) => existsSync(candidate));
122
+ if (!found) {
123
+ throw new CLIError(`No se encontró el archivo \`${filePath}\`.\n` +
124
+ `Pasa la ruta de un ticket bajo \`${FACTORY_TICKET_DIR}/\`.`);
125
+ }
126
+ let realRepo;
127
+ let realRoot;
128
+ let realTarget;
129
+ try {
130
+ realRepo = realpathSync(rootDir);
131
+ realRoot = realpathSync(ticketsRoot);
132
+ realTarget = realpathSync(found);
133
+ }
134
+ catch {
135
+ throw new CLIError(`No se pudo resolver \`${filePath}\` (¿un enlace roto?). No se entregó nada.`);
136
+ }
137
+ // The ANCHOR of the containment must itself be inside the repo. `project/factory`
138
+ // can BE a symlink — git versions symlinks with their target and materialises them
139
+ // on checkout — and anchoring the fence on its target moves the fence: with
140
+ // `project/factory -> ~/.claude`, everything under the org rail's directory would
141
+ // resolve as "contained" and become publishable. Checked before the file's own
142
+ // containment, because a compromised root makes that check meaningless.
143
+ // `rootRel === ''` counts as an escape, symmetrically with the file check below: an empty
144
+ // relative path means the tickets root resolved to the REPO itself, which widens the fence
145
+ // from `project/factory/` to the whole checkout — gitignored files included. Two versioned
146
+ // symlinks (`project/factory -> ..` and `ticket.md -> .env.local`) reach `.env.local` that way.
147
+ const rootRel = path.relative(realRepo, realRoot);
148
+ if (rootRel === '' || path.isAbsolute(rootRel) || rootRel.split(/[\\/]/)[0] === '..') {
149
+ throw new CLIError(`\`${FACTORY_TICKET_DIR}/\` sale del árbol del repo: se resolvió a \`${realRoot}\`, ` +
150
+ `fuera de \`${realRepo}\`.\n` +
151
+ 'La contención se ancla en el repo, así que un directorio de tickets que apunta afuera se ' +
152
+ 'rechaza entero — no se entregó nada.');
153
+ }
154
+ const rel = path.relative(realRoot, realTarget);
155
+ const escapes = rel === '' || path.isAbsolute(rel) || rel.split(/[\\/]/)[0] === '..';
156
+ if (escapes) {
157
+ throw new CLIError(`\`${filePath}\` no está dentro de \`${FACTORY_TICKET_DIR}/\` — se resolvió a \`${realTarget}\`.\n` +
158
+ 'Solo se entrega un archivo real contenido en ese directorio (los enlaces que salen del árbol se rechazan).');
159
+ }
160
+ if (!statSync(realTarget).isFile()) {
161
+ throw new CLIError(`\`${filePath}\` no es un archivo regular; no hay ticket que entregar.`);
162
+ }
163
+ return realTarget;
164
+ }
165
+ // ─────────────────────────────────────────────────────────────────────────────
166
+ // Stamping (`fx-factory-tickets §6.2`)
167
+ // ─────────────────────────────────────────────────────────────────────────────
168
+ /** Canonical header field the delivered URL is stamped into. It is the ONLY field a tool writes. */
169
+ const GITHUB_ISSUE_FIELD = '**GitHub issue:**';
170
+ const ESTADO_FIELD = '**Estado:**';
171
+ /**
172
+ * Write `url` into the ticket's `**GitHub issue:**` slot, which is born holding an
173
+ * em-dash and sits immediately after `**Estado:**`. Stamping the URL does NOT touch
174
+ * `Estado` — delivering is not triaging (`fx-factory-tickets §6.2`).
175
+ *
176
+ * A ticket written before the convention has no such field; it is inserted right
177
+ * after `Estado` rather than dropped, so the ticket stops being a dead end either
178
+ * way. With no header block at all, nothing is changed and the caller warns.
179
+ */
180
+ export function stampGitHubIssueUrl(content, url) {
181
+ const stamped = `${GITHUB_ISSUE_FIELD} ${url}`;
182
+ const lines = content.split('\n');
183
+ const fieldIdx = lines.findIndex((line) => line.startsWith(GITHUB_ISSUE_FIELD));
184
+ if (fieldIdx >= 0) {
185
+ lines[fieldIdx] = stamped;
186
+ return { content: lines.join('\n'), changed: true };
187
+ }
188
+ const estadoIdx = lines.findIndex((line) => line.startsWith(ESTADO_FIELD));
189
+ if (estadoIdx >= 0) {
190
+ lines.splice(estadoIdx + 1, 0, stamped);
191
+ return { content: lines.join('\n'), changed: true };
192
+ }
193
+ return { content, changed: false };
194
+ }
195
+ // ─────────────────────────────────────────────────────────────────────────────
196
+ // Terminal safety
197
+ // ─────────────────────────────────────────────────────────────────────────────
198
+ /**
199
+ * Every C0/C1 control character EXCEPT `\n` and `\t` — the ones a terminal ACTS on
200
+ * instead of printing (`\x1b` starts a CSI sequence, `\x0d` returns the carriage,
201
+ * `\x9b` is CSI on its own).
202
+ */
203
+ const CONTROL_CHARS = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g;
204
+ /**
205
+ * Render untrusted ticket text for a HUMAN to read, with every terminal control
206
+ * character shown as its own escape (`\x1b` prints as the four characters `\x1b`).
207
+ *
208
+ * WHAT THIS GUARDS. `--dry-run` prints the DERIVED TITLE, and that title is built from
209
+ * the ticket's H1 ({@link buildIssueTitle}) — a line of a file whose whole purpose is to
210
+ * carry pasted command output. Unescaped, it drives the terminal instead of appearing in
211
+ * it: `\x1b[nA` (cursor up) overwrites the destination, assignee and label printed just
212
+ * above it, and `\x1b[8m` (conceal) hides part of itself from the reviewer's eye. The
213
+ * printed preview IS the gate, so anything that can rewrite the preview defeats it.
214
+ *
215
+ * 🔴 ESCAPED, NOT STRIPPED. Sanitising would break the other promise — that what you
216
+ * approve is byte-for-byte what gets published. The PUBLISHED title is untouched: this
217
+ * only changes its representation on screen.
218
+ */
219
+ export function escapeControlChars(text) {
220
+ return text.replace(CONTROL_CHARS, (char) => `\\x${char.charCodeAt(0).toString(16).padStart(2, '0')}`);
221
+ }
222
+ // ─────────────────────────────────────────────────────────────────────────────
223
+ // Issue creation
224
+ // ─────────────────────────────────────────────────────────────────────────────
225
+ /**
226
+ * Title of the delivered issue: the ticket's H1 plus its filename. The H1 alone is
227
+ * generic by convention (`# Factory Ticket — sk-drift`), so a queue of them would be
228
+ * unreadable; the filename carries the project, the date and the counter.
229
+ */
230
+ export function buildIssueTitle(body, filePath) {
231
+ const basename = path.basename(filePath);
232
+ const heading = body.split('\n').find((line) => line.startsWith('# '));
233
+ const title = heading ? `${heading.slice(2).trim()} (${basename})` : basename;
234
+ if (title.length <= GITHUB_ISSUE_TITLE_MAX)
235
+ return title;
236
+ // Same class of input as the body (hand-written, unbounded), so the same treatment:
237
+ // capped here with the truncation VISIBLE, instead of a raw 422 from the API. The
238
+ // ellipsis eats one character so the result never exceeds the cap.
239
+ return `${title.slice(0, GITHUB_ISSUE_TITLE_MAX - 1).trimEnd()}…`;
240
+ }
241
+ /**
242
+ * `gh` rejected the ASSIGNEE (a renamed login, somebody who left the org) — recoverable.
243
+ * Symmetric to {@link isMissingLabel}: the assignee is a login hardcoded in a CLI
244
+ * published to the whole fleet, so a single change outside this repo would otherwise
245
+ * break the channel for every derivative at once.
246
+ */
247
+ function isRejectedAssignee(stderr) {
248
+ return (/assign/i.test(stderr) &&
249
+ /(not found|could not|no existe|does not exist|invalid|unable)/i.test(stderr));
250
+ }
251
+ /** `gh` rejected the label itself (it does not exist in the repo) — recoverable. */
252
+ function isMissingLabel(stderr) {
253
+ return (/label/i.test(stderr) && /(not found|could not add|no existe|does not exist)/i.test(stderr));
254
+ }
255
+ /** First issue URL printed by `gh issue create`. */
256
+ function extractIssueUrl(stdout) {
257
+ return /https:\/\/\S*\/issues\/\d+/.exec(stdout)?.[0];
258
+ }
259
+ /** Default confirmation — `prompts`, defaulting to NO (a publish is not undoable). */
260
+ async function defaultConfirm(message) {
261
+ const { proceed } = await prompts({
262
+ type: 'confirm',
263
+ name: 'proceed',
264
+ message,
265
+ initial: false,
266
+ });
267
+ return proceed === true;
268
+ }
269
+ /**
270
+ * Deliver one factory-ticket to the Factory repo as a GitHub issue.
271
+ *
272
+ * Order matters and is asserted by the unit test: containment → read → size →
273
+ * (`--dry-run` stops here) → gh session → publish gate → create → verify
274
+ * assignee/label → stamp. Every guard that can reject runs BEFORE `gh` is ever
275
+ * spawned.
276
+ */
277
+ export async function runTicketPush(filePath, flags = parseTicketPushFlags([]), rootDir = detectRepo(process.cwd()).repoRoot ?? process.cwd(), deps = {}) {
278
+ const target = resolveTicketPath(rootDir, filePath);
279
+ const body = readFileSync(target, 'utf8');
280
+ const shown = path.relative(rootDir, target) || path.basename(target);
281
+ if (body.trim().length === 0) {
282
+ throw new CLIError(`El ticket \`${shown}\` está vacío; no hay nada que entregar.`);
283
+ }
284
+ if (body.length > GITHUB_ISSUE_BODY_MAX) {
285
+ throw new CLIError(`El ticket \`${shown}\` mide ${body.length.toLocaleString('es-MX')} caracteres y GitHub ` +
286
+ `topa el cuerpo de un issue en ${GITHUB_ISSUE_BODY_MAX.toLocaleString('es-MX')}.\n` +
287
+ 'Recorta el `## Context snippet (optional)` (o divide el hallazgo en dos tickets) y vuelve a intentar.');
288
+ }
289
+ const title = buildIssueTitle(body, target);
290
+ const isTTY = deps.isTTY ?? Boolean(process.stdout.isTTY);
291
+ const confirm = deps.confirm ?? defaultConfirm;
292
+ // 1. `--dry-run` — print what would be published, mutate nothing. What reaches the screen
293
+ // is the DESTINATION and the derived title, never file content; the title is escaped so
294
+ // it cannot drive the terminal. What gets published is `body`, untouched.
295
+ if (flags.dryRun) {
296
+ console.log(`\n📋 Entrega simulada (\`--dry-run\`) de \`${shown}\`:\n`);
297
+ console.log(` Repo destino : ${FACTORY_REPO}`);
298
+ console.log(` Asignado : ${flags.assignee}`);
299
+ console.log(` Etiqueta : ${flags.label}`);
300
+ // Escaped: the title is BUILT from the H1 of the ticket, so it carries the same untrusted
301
+ // bytes as any other line of it (see `escapeControlChars`).
302
+ console.log(` Título : ${escapeControlChars(title)}`);
303
+ console.log(` Cuerpo : ${body.split('\n').length} líneas — ábrelo en \`${shown}\``);
304
+ // 🔴 THE BODY IS NEVER PRINTED, and there is no flag that prints it.
305
+ //
306
+ // Not a mitigation — a dissolved question. Why print the body at all? It is a file in the
307
+ // user's own repo: they can open it, and since the published body is byte-identical,
308
+ // opening the file IS seeing what would be published. What this command uniquely knows is
309
+ // what the user cannot see alone: the destination, the assignee, the label and the derived
310
+ // title. Printing those and nothing else means no file content reaches the screen at all.
311
+ //
312
+ // No `--show-body` escape hatch, deliberately: the caller most likely to run `--dry-run` is
313
+ // an agent checking before publishing, its output lands in a transcript nobody can purge,
314
+ // and a flag is exactly what an agent would pass.
315
+ console.log('\nNo se creó ningún issue (`--dry-run`). El cuerpo no se imprime — ábrelo tú.');
316
+ return;
317
+ }
318
+ // 2. gh session. A failed preflight is `not-configured`, NOT an error: note + exit 0.
319
+ try {
320
+ await (deps.preflight ?? runPreflight)();
321
+ }
322
+ catch (error) {
323
+ if (error instanceof PreflightError) {
324
+ console.log(`ℹ️ Entrega omitida: ${error.message}`);
325
+ console.log(' El ticket sigue en tu repo, intacto. Este comando usa tu propia sesión de `gh` — ' +
326
+ 'no hay ningún token que configurar.');
327
+ return;
328
+ }
329
+ throw error;
330
+ }
331
+ // 3. Publish gate. The body lands in a repo the whole org can read, so it never goes out
332
+ // unasked: headless without `--yes` is fail-closed, a TTY is asked, `--yes` is the answer
333
+ // given in advance.
334
+ if (!flags.yes) {
335
+ if (!isTTY) {
336
+ throw new CLIError(`No se publica sin confirmación: \`${shown}\` se publicaría íntegro en ${FACTORY_REPO}.\n` +
337
+ 'Revisa el contenido con `--dry-run` y, si es correcto, vuelve a correr con `--yes`.');
338
+ }
339
+ const proceed = await confirm(`¿Crear el issue en ${FACTORY_REPO} con el contenido de \`${shown}\`?`);
340
+ if (!proceed) {
341
+ console.log('Cancelado. No se creó ningún issue.');
342
+ return;
343
+ }
344
+ }
345
+ // 4. Create the issue. Body over STDIN; label applied, never created.
346
+ //
347
+ // TWO RUNGS OF DEGRADATION, and they are symmetric on purpose. Both the label and
348
+ // the assignee are values this CLI hardcodes for the WHOLE fleet, so both can be
349
+ // invalidated by a single change outside this repo (a deleted label, a renamed
350
+ // login, somebody leaving the org). Neither may turn the channel into a dead end:
351
+ // a rejected one degrades to a retry without it plus a warning, never a failure.
352
+ const coreArgs = [
353
+ 'issue',
354
+ 'create',
355
+ '--repo',
356
+ FACTORY_REPO,
357
+ '--title',
358
+ title,
359
+ '--body-file',
360
+ '-',
361
+ ];
362
+ let labelDropped = false;
363
+ let assigneeDropped = false;
364
+ const create = () => execa('gh', [
365
+ ...coreArgs,
366
+ ...(assigneeDropped ? [] : ['--assignee', flags.assignee]),
367
+ ...(labelDropped ? [] : ['--label', flags.label]),
368
+ ], { input: body, reject: false });
369
+ let result = await create();
370
+ if (result.exitCode !== 0 && isMissingLabel(String(result.stderr ?? ''))) {
371
+ labelDropped = true;
372
+ result = await create();
373
+ }
374
+ if (result.exitCode !== 0 && isRejectedAssignee(String(result.stderr ?? ''))) {
375
+ assigneeDropped = true;
376
+ result = await create();
377
+ }
378
+ if (result.exitCode !== 0) {
379
+ throw new CLIError(`\`gh issue create\` falló (código ${result.exitCode}) y no se reintenta a ciegas.\n` +
380
+ `${String(result.stderr ?? '').trim()}\n` +
381
+ `Si el asignado del canal ya no es válido (login renombrado, o alguien que salió del org), ` +
382
+ `pasa uno con \`--assignee <login>\`.\n` +
383
+ `El ticket \`${shown}\` quedó intacto; ningún issue se creó a medias.`);
384
+ }
385
+ const stdout = String(result.stdout ?? '');
386
+ const url = extractIssueUrl(stdout);
387
+ if (!url) {
388
+ console.log(`✅ Issue creado en ${FACTORY_REPO}.`);
389
+ console.log(stdout.trim());
390
+ console.warn('⚠️ No se pudo leer la URL de la salida de `gh`, así que el ticket local no se estampó. ' +
391
+ 'Copia la URL de arriba al campo `**GitHub issue:**`.');
392
+ return;
393
+ }
394
+ console.log(`✅ Issue creado: ${url}`);
395
+ if (assigneeDropped) {
396
+ console.warn(`⚠️ ${FACTORY_REPO} rechazó al asignado \`${flags.assignee}\`; el issue se creó SIN asignado.\n` +
397
+ ' El canal quedó sin ruta de aviso al maintainer: avísale a mano, y corre la próxima ' +
398
+ 'entrega con `--assignee <login>` (o pide que se actualice el default del CLI).');
399
+ }
400
+ if (labelDropped) {
401
+ console.warn(`⚠️ La etiqueta \`${flags.label}\` no existe en ${FACTORY_REPO}; el issue se creó sin ella.\n` +
402
+ ` Un maintainer la crea una vez (\`gh label create ${flags.label} -R ${FACTORY_REPO}\`) y las ` +
403
+ 'siguientes entregas la aplican solas.');
404
+ }
405
+ // 5. Verify the effect of assignee + label instead of assuming it. GitHub DROPS
406
+ // both SILENTLY when the author lacks `triage` on the repo, and the org's base
407
+ // permission is `read` — so the two notification mechanisms of this channel can
408
+ // fail at once, without a word, unless we look.
409
+ await verifyIssueFields(url, flags, labelDropped, assigneeDropped);
410
+ // 6. Stamp the URL back. If this fails, the URL is already on screen (step above):
411
+ // never leave the dev without the URL of an issue that already exists.
412
+ const stamp = stampGitHubIssueUrl(body, url);
413
+ if (!stamp.changed) {
414
+ console.warn(`⚠️ \`${shown}\` no tiene el campo \`${GITHUB_ISSUE_FIELD}\` ni \`${ESTADO_FIELD}\`, así que no se estampó. ` +
415
+ 'Agrega la URL a mano (`fx-factory-tickets §6.2`).');
416
+ return;
417
+ }
418
+ try {
419
+ (deps.writeFile ?? ((p, c) => writeFileSync(p, c, 'utf8')))(target, stamp.content);
420
+ console.log(` URL estampada en \`${shown}\` (el \`Estado\` no cambia: entregar no es triar).`);
421
+ }
422
+ catch (error) {
423
+ console.warn(`⚠️ El issue existe (${url}) pero \`${shown}\` no se pudo actualizar: ` +
424
+ `${error instanceof Error ? error.message : String(error)}\n` +
425
+ ` Copia la URL al campo \`${GITHUB_ISSUE_FIELD}\` a mano.`);
426
+ }
427
+ }
428
+ /**
429
+ * `factory ticket <sub> [...]` — the whole dispatch of the command, HERE and not in
430
+ * `index.ts`, so the three branches (`push`, a missing file, an unknown sub-command)
431
+ * are reachable from a test. The entrypoint only routes `case 'ticket'` to this.
432
+ */
433
+ export async function runTicketCommand(args) {
434
+ const sub = args[0];
435
+ // `factory ticket push <archivo>` — deliver a factory-ticket from `project/factory/`
436
+ // to the Factory repo as a GitHub issue (EXTPT-003).
437
+ if (sub === 'push') {
438
+ const rest = args.slice(1);
439
+ const file = firstPositional(rest);
440
+ if (!file) {
441
+ throw new CLIError('Falta el archivo del ticket. Uso: `factory ticket push <archivo>` ' +
442
+ '(una ruta bajo `project/factory/`).');
443
+ }
444
+ await runTicketPush(file, parseTicketPushFlags(rest));
445
+ return;
446
+ }
447
+ throw new CLIError(`Subcomando de ticket desconocido: \`${sub ?? '(ninguno)'}\`. ` +
448
+ 'Uso: `factory ticket push <archivo>`.');
449
+ }
450
+ /**
451
+ * Read back the created issue and say plainly whether the assignee and the label
452
+ * actually stuck. Silence here would be the worst outcome: the dev believes the
453
+ * maintainer was notified and the queue is greppable, and neither is true.
454
+ */
455
+ async function verifyIssueFields(url, flags, labelDropped, assigneeDropped) {
456
+ const view = await execa('gh', ['issue', 'view', url, '--json', 'assignees,labels'], {
457
+ reject: false,
458
+ });
459
+ if (view.exitCode !== 0) {
460
+ console.warn('⚠️ No se pudo verificar el asignado ni la etiqueta del issue creado ' +
461
+ `(\`gh issue view\` salió con ${view.exitCode}). Ábrelo y confírmalo a mano.`);
462
+ return;
463
+ }
464
+ let parsed;
465
+ try {
466
+ parsed = JSON.parse(String(view.stdout ?? '{}'));
467
+ }
468
+ catch {
469
+ console.warn('⚠️ No se pudo leer la respuesta de `gh issue view`; verifica el issue a mano.');
470
+ return;
471
+ }
472
+ // A field WE dropped on purpose was already reported by the caller with its real
473
+ // reason; re-reporting it here as "GitHub lo descartó en silencio" would be false.
474
+ const noAssignee = (!parsed.assignees || parsed.assignees.length === 0) && !assigneeDropped;
475
+ const noLabel = (!parsed.labels || parsed.labels.length === 0) && !labelDropped;
476
+ if (!noAssignee && !noLabel)
477
+ return;
478
+ if (noAssignee) {
479
+ console.warn(`⚠️ El issue quedó SIN ASIGNADO: GitHub descartó \`${flags.assignee}\` en silencio.`);
480
+ }
481
+ if (noLabel) {
482
+ console.warn(`⚠️ El issue quedó sin la etiqueta \`${flags.label}\`: GitHub la descartó en silencio.`);
483
+ }
484
+ console.warn(` GitHub descarta asignado y etiquetas cuando quien crea el issue no tiene al menos \`triage\` ` +
485
+ `sobre ${FACTORY_REPO} (el permiso base del org es \`read\`).\n` +
486
+ ' Pide `triage` sobre el repo, o el ticket queda sin ruta de aviso al maintainer.');
487
+ }
package/dist/index.js CHANGED
@@ -22,6 +22,7 @@ import { parseProvisionFlags, runProvision } from './commands/provision.js';
22
22
  import { parsePublishArgs, runPublish } from './commands/publish.js';
23
23
  import { secretsCommand } from './commands/secrets.js';
24
24
  import { parseStatusFlags, runStatus } from './commands/status.js';
25
+ import { runTicketCommand } from './commands/ticket.js';
25
26
  import { parseUnpublishTargets, runUnpublish } from './commands/unpublish.js';
26
27
  import { maybeOfferAdopt, parseUpdateFlags, runUpdate } from './commands/update.js';
27
28
  import { detectRepo } from './lib/repo-detection.js';
@@ -44,6 +45,7 @@ Comandos:
44
45
  unpublish [qué] Baja del hub un entregable publicado (proposal|mockup|both; default both)
45
46
  provision Aprovisiona la infra de un derivado (Neon/Vercel/GitHub/DNS/backlog) — flag-driven, --json
46
47
  backlog <sub> push: refleja el backlog local en backlog.timekast.mx · sync: status + self-healing
48
+ ticket <sub> push: entrega un factory-ticket de project/factory/ al repo del Factory
47
49
  secrets <file> Mergea un archivo KEY=value de secretos de metodología a ~/.claude/.env (chmod 600)
48
50
  env <sub> push: sube product vars a Vercel (day-2) · wizard: configura .env.local
49
51
  invite-admin Crea una invitación de super_admin para un derivado (sin password en texto plano)
@@ -92,6 +94,13 @@ Sobre \`provision\`:
92
94
  --slug=<slug> Identificador del stack (nombre del proyecto + dominios interinos)
93
95
  --team=<id> Team de Vercel (override; default = \`VERCEL_TEAM_ID\` del rail ~/.claude/.env)
94
96
  --org-id=<id> Org de GitHub (override; default = org del Factory)
97
+ --neon-org=<x> Organización de Neon donde CREAR el proyecto: nombre o id.
98
+ Default = \`NEON_ORG\` del rail; si la key ve una sola org, esa.
99
+ Varias orgs y sin selector → aborta (nunca adivina). Un proyecto
100
+ que YA existe ignora este flag: su org se lee del proyecto
101
+ --remint Reacuña la API key project-scoped de Neon aunque el paso esté
102
+ completo (revoca la vigente, sube los 3 secrets de E2E y
103
+ reescribe .env.local). Para una key revocada por fuera
95
104
  --domain=<dom> Dominio definitivo del cliente (diferible; standalone o inicial)
96
105
  --dns=<ruta> Ruta DNS para --domain: org (default) | cliente-cf | registrar
97
106
  --verify-timeout=<min> Timeout del sondeo de verificación de Vercel (default 10)
@@ -142,6 +151,19 @@ Sobre \`backlog\`:
142
151
  --epic <ID> --status <s> Equivalente para una épica
143
152
  --heal-only Corre solo el self-healing (sin el sync puntual)
144
153
 
154
+ Sobre \`ticket\`:
155
+ \`ticket push <archivo>\` entrega un factory-ticket de \`project/factory/\` al repo
156
+ del Factory como un issue de GitHub, y estampa la URL de vuelta en el campo
157
+ \`**GitHub issue:**\` del ticket (el \`Estado\` no cambia: entregar no es triar).
158
+ Cero credenciales nuevas: usa tu propia sesión de \`gh\`; sin sesión avisa y sale 0.
159
+ El cuerpo se publica ÍNTEGRO en un repo compartido, así que hay compuerta:
160
+ --dry-run Imprime destino, asignado, etiqueta y título; no crea nada
161
+ --yes / -y Salta la confirmación interactiva (obligatorio en headless)
162
+ --assignee <u> Asignado del issue (default: el maintainer del Factory)
163
+ --label <l> Etiqueta a aplicar (default: kit-drift; se aplica, no se crea)
164
+ El cuerpo NO se imprime: es un archivo de tu propio repo y lo publicado es idéntico,
165
+ así que abrirlo es verlo. Revísalo antes de confirmar.
166
+
145
167
  Sobre \`invite-admin\`:
146
168
  Materializa el primer super_admin de un derivado por invitación (reemplaza el
147
169
  viejo seed con password en texto plano). Resuelve la connection string del
@@ -262,6 +284,13 @@ async function main(argv) {
262
284
  throw new CLIError(`Subcomando de backlog desconocido: \`${sub ?? '(ninguno)'}\`. ` +
263
285
  'Uso: `factory backlog push` | `factory backlog sync`.');
264
286
  }
287
+ case 'ticket': {
288
+ // `factory ticket push <archivo>` — deliver a factory-ticket from
289
+ // `project/factory/` to the Factory repo as a GitHub issue (EXTPT-003).
290
+ // The dispatch lives in the command module so its branches are testable.
291
+ await runTicketCommand(rest);
292
+ return;
293
+ }
265
294
  case 'publish': {
266
295
  await runPublish(parsePublishArgs(rest));
267
296
  return;