@timekast/factory 1.25.0 → 1.27.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.
- package/dist/commands/add.js +2 -0
- package/dist/commands/doctor.js +5 -4
- package/dist/commands/env.js +110 -24
- package/dist/commands/provision.js +24 -17
- package/dist/commands/ticket.js +487 -0
- package/dist/commands/update.js +13 -2
- package/dist/index.js +28 -1
- package/dist/lib/constants.js +172 -0
- package/dist/lib/env-file.js +239 -0
- package/dist/lib/env-wizard.js +110 -21
- package/dist/lib/exit-code.js +31 -0
- package/dist/lib/initial-migration.js +1 -1
- package/dist/lib/package-json.js +70 -2
- package/dist/lib/public-env-keys.json +27 -0
- package/dist/lib/retrofit-reminder.js +46 -3
- package/package.json +1 -1
|
@@ -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/commands/update.js
CHANGED
|
@@ -67,6 +67,7 @@ export function parseUpdateFlags(argv) {
|
|
|
67
67
|
beta: argv.includes('--beta'),
|
|
68
68
|
stable: argv.includes('--stable'),
|
|
69
69
|
yes: argv.includes('--yes') || argv.includes('-y'),
|
|
70
|
+
launcher: argv.includes('--launcher'),
|
|
70
71
|
};
|
|
71
72
|
}
|
|
72
73
|
/** Default acquire: download the sticky-profile tarball, stage + validate it. */
|
|
@@ -566,7 +567,17 @@ function syncDerivedPrettierignore(rootDir, stagedDir) {
|
|
|
566
567
|
const KIT_TESTS_DIR = 'scripts/tools/__tests__/';
|
|
567
568
|
/** Exit code of `--dry-run` when the preview found conflicts (0 = none). Lets a
|
|
568
569
|
* non-interactive caller (the GUI) branch on the result instead of clobbering. */
|
|
569
|
-
|
|
570
|
+
/**
|
|
571
|
+
* 🔴 FROZEN WIRE FORMAT — the same class of contract as the retrofit signal marker
|
|
572
|
+
* (ADR-004 §1/§4), and for the same reason. `update --dry-run` reports "the preview found
|
|
573
|
+
* conflicts" with this code, and the GUI launcher reads it to decide whether it may chain the
|
|
574
|
+
* real apply. Its twin is calqued BY VALUE in `desktop/renderer/panels/update-project.tsx`
|
|
575
|
+
* (`DRY_RUN_CONFLICTS_EXIT`) — two npm packages with no dependency edge, so no compiler will
|
|
576
|
+
* ever catch a drift. Changing this number silently kills the launcher's clobber guard, which
|
|
577
|
+
* is exactly the failure that shipped once already (see `cli/src/lib/exit-code.ts`).
|
|
578
|
+
* Exported so the golden pin in the test suite can assert it.
|
|
579
|
+
*/
|
|
580
|
+
export const DRY_RUN_CONFLICTS_EXIT = 2;
|
|
570
581
|
/**
|
|
571
582
|
* `--dry-run`: preview the plan (buckets + the explicit conflict list) and apply
|
|
572
583
|
* NOTHING. Prints the conflicting paths so a human or agent sees exactly what
|
|
@@ -859,7 +870,7 @@ export async function runUpdate(flags, deps = {}) {
|
|
|
859
870
|
// `oldLock.version` is the version BEFORE this update, which is what separates the guides
|
|
860
871
|
// that just arrived from the ones that were already sitting there. A legacy repo carries
|
|
861
872
|
// `0.0.0`, so every guide reads as new — correct: it has seen none of them.
|
|
862
|
-
printRetrofitReminder({ rootDir, fromVersion: oldLock.version });
|
|
873
|
+
printRetrofitReminder({ rootDir, fromVersion: oldLock.version, guiSignal: flags.launcher });
|
|
863
874
|
// End-pointer to the next step (PVSKILL-002). Reached only after a clean apply;
|
|
864
875
|
// `--verify`/`--resume` short-circuit above (verify NEVER prints), an error path
|
|
865
876
|
// throws earlier, and headless `--yes` is suppressed (CI noise).
|
package/dist/index.js
CHANGED
|
@@ -10,6 +10,7 @@ import { readFileSync } from 'node:fs';
|
|
|
10
10
|
import { dirname, join } from 'node:path';
|
|
11
11
|
import { fileURLToPath } from 'node:url';
|
|
12
12
|
import { CLIError } from './lib/cli-error.js';
|
|
13
|
+
import { resolveSuccessExitCode } from './lib/exit-code.js';
|
|
13
14
|
import { parseAddFlags, runAdd } from './commands/add.js';
|
|
14
15
|
import { parseBacklogPushFlags, parseBacklogSyncFlags, runBacklogPush, runBacklogSync, } from './commands/backlog.js';
|
|
15
16
|
import { parseEnvPushFlags, parseEnvWizardFlags, runEnvPush, runEnvWizardApply, runEnvWizardGenVapid, runEnvWizardPlan, runEnvWizardReview, } from './commands/env.js';
|
|
@@ -21,6 +22,7 @@ import { parseProvisionFlags, runProvision } from './commands/provision.js';
|
|
|
21
22
|
import { parsePublishArgs, runPublish } from './commands/publish.js';
|
|
22
23
|
import { secretsCommand } from './commands/secrets.js';
|
|
23
24
|
import { parseStatusFlags, runStatus } from './commands/status.js';
|
|
25
|
+
import { runTicketCommand } from './commands/ticket.js';
|
|
24
26
|
import { parseUnpublishTargets, runUnpublish } from './commands/unpublish.js';
|
|
25
27
|
import { maybeOfferAdopt, parseUpdateFlags, runUpdate } from './commands/update.js';
|
|
26
28
|
import { detectRepo } from './lib/repo-detection.js';
|
|
@@ -43,6 +45,7 @@ Comandos:
|
|
|
43
45
|
unpublish [qué] Baja del hub un entregable publicado (proposal|mockup|both; default both)
|
|
44
46
|
provision Aprovisiona la infra de un derivado (Neon/Vercel/GitHub/DNS/backlog) — flag-driven, --json
|
|
45
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
|
|
46
49
|
secrets <file> Mergea un archivo KEY=value de secretos de metodología a ~/.claude/.env (chmod 600)
|
|
47
50
|
env <sub> push: sube product vars a Vercel (day-2) · wizard: configura .env.local
|
|
48
51
|
invite-admin Crea una invitación de super_admin para un derivado (sin password en texto plano)
|
|
@@ -141,6 +144,19 @@ Sobre \`backlog\`:
|
|
|
141
144
|
--epic <ID> --status <s> Equivalente para una épica
|
|
142
145
|
--heal-only Corre solo el self-healing (sin el sync puntual)
|
|
143
146
|
|
|
147
|
+
Sobre \`ticket\`:
|
|
148
|
+
\`ticket push <archivo>\` entrega un factory-ticket de \`project/factory/\` al repo
|
|
149
|
+
del Factory como un issue de GitHub, y estampa la URL de vuelta en el campo
|
|
150
|
+
\`**GitHub issue:**\` del ticket (el \`Estado\` no cambia: entregar no es triar).
|
|
151
|
+
Cero credenciales nuevas: usa tu propia sesión de \`gh\`; sin sesión avisa y sale 0.
|
|
152
|
+
El cuerpo se publica ÍNTEGRO en un repo compartido, así que hay compuerta:
|
|
153
|
+
--dry-run Imprime destino, asignado, etiqueta y título; no crea nada
|
|
154
|
+
--yes / -y Salta la confirmación interactiva (obligatorio en headless)
|
|
155
|
+
--assignee <u> Asignado del issue (default: el maintainer del Factory)
|
|
156
|
+
--label <l> Etiqueta a aplicar (default: kit-drift; se aplica, no se crea)
|
|
157
|
+
El cuerpo NO se imprime: es un archivo de tu propio repo y lo publicado es idéntico,
|
|
158
|
+
así que abrirlo es verlo. Revísalo antes de confirmar.
|
|
159
|
+
|
|
144
160
|
Sobre \`invite-admin\`:
|
|
145
161
|
Materializa el primer super_admin de un derivado por invitación (reemplaza el
|
|
146
162
|
viejo seed con password en texto plano). Resuelve la connection string del
|
|
@@ -261,6 +277,13 @@ async function main(argv) {
|
|
|
261
277
|
throw new CLIError(`Subcomando de backlog desconocido: \`${sub ?? '(ninguno)'}\`. ` +
|
|
262
278
|
'Uso: `factory backlog push` | `factory backlog sync`.');
|
|
263
279
|
}
|
|
280
|
+
case 'ticket': {
|
|
281
|
+
// `factory ticket push <archivo>` — deliver a factory-ticket from
|
|
282
|
+
// `project/factory/` to the Factory repo as a GitHub issue (EXTPT-003).
|
|
283
|
+
// The dispatch lives in the command module so its branches are testable.
|
|
284
|
+
await runTicketCommand(rest);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
264
287
|
case 'publish': {
|
|
265
288
|
await runPublish(parsePublishArgs(rest));
|
|
266
289
|
return;
|
|
@@ -328,7 +351,11 @@ async function main(argv) {
|
|
|
328
351
|
}
|
|
329
352
|
}
|
|
330
353
|
main(process.argv.slice(2)).then(() => {
|
|
331
|
-
process.exit(0)
|
|
354
|
+
// NOT a bare `process.exit(0)`. An explicit argument overrides `process.exitCode`, so
|
|
355
|
+
// hardcoding 0 threw away the outcome code of any command that succeeded but had
|
|
356
|
+
// something to report — `update --dry-run`'s "there are conflicts" (exit 2) among them,
|
|
357
|
+
// which left the launcher's clobber guard dead. See `resolveSuccessExitCode`.
|
|
358
|
+
process.exit(resolveSuccessExitCode(process.exitCode));
|
|
332
359
|
}, (error) => {
|
|
333
360
|
if (error instanceof CLIError) {
|
|
334
361
|
// An empty message is intentional (the command already rendered its own
|