@timekast/factory 2.4.0 → 2.5.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 +29 -8
- package/dist/commands/doctor.js +32 -3
- package/dist/commands/env.js +16 -4
- package/dist/commands/provision.js +85 -27
- package/dist/commands/prune.js +60 -10
- package/dist/commands/update.js +104 -85
- package/dist/commands/usage.js +303 -0
- package/dist/commands/vault.js +81 -18
- package/dist/index.js +26 -0
- package/dist/lib/backlog-mapping.js +1 -1
- package/dist/lib/claude-paths.js +8 -2
- package/dist/lib/claude-settings.js +104 -0
- package/dist/lib/constants.js +26 -0
- package/dist/lib/derived-dotfiles.js +165 -0
- package/dist/lib/email-from.js +12 -4
- package/dist/lib/initial-writes.js +113 -0
- package/dist/lib/kit-deps.js +163 -0
- package/dist/lib/kit-settings-history.json +13 -0
- package/dist/lib/lockfile.js +10 -5
- package/dist/lib/prune-remote.js +8 -6
- package/dist/lib/prune-vercel.js +137 -0
- package/dist/lib/rail.js +5 -3
- package/dist/lib/railway-api.js +3 -0
- package/dist/lib/usage-scan.js +516 -0
- package/dist/lib/vault-adopt.js +103 -5
- package/dist/lib/vault-api.js +2 -2
- package/dist/lib/vercel-env.js +21 -0
- package/package.json +1 -1
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a FIRST install (`factory add`, and the legacy auto-register of `factory update`) may
|
|
3
|
+
* write over a repo that already has files of its own (Factory#76).
|
|
4
|
+
*
|
|
5
|
+
* Both paths used to write every manifest file without looking, which is right for the brain
|
|
6
|
+
* (`.claude/`, `scripts/tools/`) and wrong for the handful of kit files that live among the
|
|
7
|
+
* repo's own: an `add --full` replaced a repo's `.github/workflows/ci.yml` with the kit
|
|
8
|
+
* template and dropped its build, E2E and gitleaks steps. Three classes, decided by path:
|
|
9
|
+
*
|
|
10
|
+
* - brain (`BRAIN_PREFIXES`) → always written, as before;
|
|
11
|
+
* - `CLAUDE.md` → written only when absent (dev-owned content, unchanged rule);
|
|
12
|
+
* - every other kit file → written when absent; left alone when it already holds the kit's
|
|
13
|
+
* content; KEPT when it holds something else, with the kit's copy written next to it as
|
|
14
|
+
* `<path>.kit-example` for the team to compare.
|
|
15
|
+
*
|
|
16
|
+
* The lockfile still records the KIT hash for a kept file (it is the embedded manifest), the
|
|
17
|
+
* same as a preserved `CLAUDE.md`. On the next `update` the diff engine then reads it as a local
|
|
18
|
+
* edit: kept silently while the kit does not change it (`ignoreLocal`), a mine/theirs prompt
|
|
19
|
+
* when it does.
|
|
20
|
+
*/
|
|
21
|
+
import { copyFileSync, existsSync, readFileSync, statSync } from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { BRAIN_PREFIXES, CLAUDE_MD_FILE, KIT_EXAMPLE_SUFFIX } from './constants.js';
|
|
24
|
+
import { normalizeThenHash } from './lockfile.js';
|
|
25
|
+
/** Classify a manifest path (see the module header for what each class does). */
|
|
26
|
+
export function classifyInitialWrite(relPath) {
|
|
27
|
+
if (BRAIN_PREFIXES.some((prefix) => relPath.startsWith(prefix)))
|
|
28
|
+
return 'brain';
|
|
29
|
+
if (relPath === CLAUDE_MD_FILE)
|
|
30
|
+
return 'claude-md';
|
|
31
|
+
return 'repo';
|
|
32
|
+
}
|
|
33
|
+
/** The kit's copy of a kept repo file: the suffix goes after the WHOLE name. */
|
|
34
|
+
export function kitExamplePath(relPath) {
|
|
35
|
+
return `${relPath}${KIT_EXAMPLE_SUFFIX}`;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Split the manifest into what a first install writes and what it keeps. Reads the repo, writes
|
|
39
|
+
* nothing. A `repo`-class file whose normalized hash equals the kit's is neither written nor
|
|
40
|
+
* kept: it already is the kit's file.
|
|
41
|
+
*/
|
|
42
|
+
export function partitionInitialWrites(root, manifest) {
|
|
43
|
+
const writes = [];
|
|
44
|
+
const kept = [];
|
|
45
|
+
for (const entry of manifest.files) {
|
|
46
|
+
const abs = path.join(root, entry.path);
|
|
47
|
+
const onDisk = existsSync(abs) && statSync(abs).isFile();
|
|
48
|
+
switch (classifyInitialWrite(entry.path)) {
|
|
49
|
+
case 'brain':
|
|
50
|
+
writes.push(entry.path);
|
|
51
|
+
break;
|
|
52
|
+
case 'claude-md':
|
|
53
|
+
if (!onDisk)
|
|
54
|
+
writes.push(entry.path);
|
|
55
|
+
break;
|
|
56
|
+
case 'repo':
|
|
57
|
+
if (!onDisk)
|
|
58
|
+
writes.push(entry.path);
|
|
59
|
+
else if (normalizeThenHash(readFileSync(abs, 'utf8')) !== entry.hash)
|
|
60
|
+
kept.push(entry.path);
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return { writes, kept };
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Copy the kit's version of each kept file from the staged tarball to `<path>.kit-example`.
|
|
68
|
+
* Returns the example paths actually written (a path missing from the staged dir is skipped).
|
|
69
|
+
*/
|
|
70
|
+
export function writeKitExamples(root, stagedDir, kept) {
|
|
71
|
+
const written = [];
|
|
72
|
+
for (const rel of kept) {
|
|
73
|
+
const src = path.join(stagedDir, rel);
|
|
74
|
+
if (!existsSync(src))
|
|
75
|
+
continue;
|
|
76
|
+
copyFileSync(src, path.join(root, kitExamplePath(rel)));
|
|
77
|
+
written.push(kitExamplePath(rel));
|
|
78
|
+
}
|
|
79
|
+
return written;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* `update`: refresh every `<path>.kit-example` already on disk with the incoming kit version,
|
|
83
|
+
* so the copy the team compares against never goes stale. Only paths of the manifest, only
|
|
84
|
+
* examples that already exist (an update never creates one), only when the bytes change.
|
|
85
|
+
* Returns the example paths rewritten.
|
|
86
|
+
*/
|
|
87
|
+
export function refreshKitExamples(root, stagedDir, manifestPaths) {
|
|
88
|
+
const refreshed = [];
|
|
89
|
+
for (const rel of manifestPaths) {
|
|
90
|
+
if (classifyInitialWrite(rel) !== 'repo')
|
|
91
|
+
continue;
|
|
92
|
+
const example = path.join(root, kitExamplePath(rel));
|
|
93
|
+
const src = path.join(stagedDir, rel);
|
|
94
|
+
if (!existsSync(example) || !existsSync(src))
|
|
95
|
+
continue;
|
|
96
|
+
const incoming = readFileSync(src);
|
|
97
|
+
if (readFileSync(example).equals(incoming))
|
|
98
|
+
continue;
|
|
99
|
+
copyFileSync(src, example);
|
|
100
|
+
refreshed.push(kitExamplePath(rel));
|
|
101
|
+
}
|
|
102
|
+
return refreshed;
|
|
103
|
+
}
|
|
104
|
+
/** Tell the dev which files were kept and where the kit's version went. */
|
|
105
|
+
export function reportKeptRepoFiles(kept) {
|
|
106
|
+
if (kept.length === 0)
|
|
107
|
+
return;
|
|
108
|
+
console.log('\nConservé estos archivos tuyos; la versión del kit quedó al lado para comparar:');
|
|
109
|
+
for (const rel of kept)
|
|
110
|
+
console.log(` • ${rel} → ${kitExamplePath(rel)}`);
|
|
111
|
+
console.log(' Si quieres algo de la versión del kit, cópialo a mano. Mientras el kit no cambie esos ' +
|
|
112
|
+
'archivos, `factory update` los deja como están; si los cambia, te pregunta cuál conservar.');
|
|
113
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The npm packages the kit's files need, against the ones the repo declares (Factory#78).
|
|
3
|
+
*
|
|
4
|
+
* A derivative of an older kit (or an app that never was one) gets kit files that import
|
|
5
|
+
* packages it does not have — `sharp`, `@testing-library/*`, `knip`… — and its typecheck,
|
|
6
|
+
* `next build` and CI go red with nothing telling it why. The builder embeds the list in
|
|
7
|
+
* every tarball (`.timekast/kit-deps.json`, `distribution/build-dist.ts#buildKitDeps`):
|
|
8
|
+
* each package with the range the Factory declares. `add` and `update` compare it with the
|
|
9
|
+
* repo's `package.json` and OFFER to install what is missing:
|
|
10
|
+
*
|
|
11
|
+
* · interactive terminal → asks, and on yes runs `<pm> add -D …` in the repo;
|
|
12
|
+
* · no terminal, or `--yes` → only prints the exact command (never installs blindly —
|
|
13
|
+
* installing a dependency is the dev's call, `CODING.md §7`);
|
|
14
|
+
* · `--dry-run` → only lists them.
|
|
15
|
+
*
|
|
16
|
+
* A repo without `package.json` is not a Node project: nothing is offered, and none is
|
|
17
|
+
* ever created. A tarball from before this list existed has no file: nothing is offered.
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { execa } from 'execa';
|
|
22
|
+
import prompts from 'prompts';
|
|
23
|
+
import { KIT_DEPS_FILE, TIMEKAST_DIR } from './constants.js';
|
|
24
|
+
import { PACKAGE_JSON_FILE, PNPM_LOCKFILE } from './initial-migration.js';
|
|
25
|
+
/** Lockfile → package manager, in detection order. No lockfile → `pnpm` (the kit's). */
|
|
26
|
+
const PM_LOCKFILES = [
|
|
27
|
+
[PNPM_LOCKFILE, 'pnpm'],
|
|
28
|
+
['yarn.lock', 'yarn'],
|
|
29
|
+
['package-lock.json', 'npm'],
|
|
30
|
+
['bun.lockb', 'bun'],
|
|
31
|
+
['bun.lock', 'bun'],
|
|
32
|
+
];
|
|
33
|
+
/** The "add as devDependency" verb of each package manager. */
|
|
34
|
+
const PM_ADD_DEV = {
|
|
35
|
+
pnpm: ['add', '-D'],
|
|
36
|
+
yarn: ['add', '-D'],
|
|
37
|
+
npm: ['install', '-D'],
|
|
38
|
+
bun: ['add', '-d'],
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Read the list from a STAGED tarball. Absent (a release from before the list) → `null`,
|
|
42
|
+
* silently. Unreadable or with the wrong shape → `null` with a warning: the brain still
|
|
43
|
+
* installs, the offer is just skipped.
|
|
44
|
+
*/
|
|
45
|
+
export function readKitDeps(stagedDir) {
|
|
46
|
+
const file = path.join(stagedDir, TIMEKAST_DIR, KIT_DEPS_FILE);
|
|
47
|
+
if (!existsSync(file))
|
|
48
|
+
return null;
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
51
|
+
const packages = parsed.packages;
|
|
52
|
+
if (typeof packages !== 'object' ||
|
|
53
|
+
packages === null ||
|
|
54
|
+
Array.isArray(packages) ||
|
|
55
|
+
!Object.values(packages).every((range) => typeof range === 'string')) {
|
|
56
|
+
throw new Error('shape');
|
|
57
|
+
}
|
|
58
|
+
return parsed;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
console.warn(`Aviso: el \`${TIMEKAST_DIR}/${KIT_DEPS_FILE}\` del tarball no se pudo leer; omito la revisión de dependencias.`);
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The kit packages the repo's `package.json` does not declare (in `dependencies`,
|
|
67
|
+
* `devDependencies`, `optionalDependencies` or `peerDependencies`), as `name@range`
|
|
68
|
+
* specs sorted by name — pure.
|
|
69
|
+
*/
|
|
70
|
+
export function missingKitDeps(pkg, kitDeps) {
|
|
71
|
+
const declared = new Set();
|
|
72
|
+
for (const field of [
|
|
73
|
+
'dependencies',
|
|
74
|
+
'devDependencies',
|
|
75
|
+
'optionalDependencies',
|
|
76
|
+
'peerDependencies',
|
|
77
|
+
]) {
|
|
78
|
+
const deps = pkg[field];
|
|
79
|
+
if (deps && typeof deps === 'object')
|
|
80
|
+
for (const name of Object.keys(deps))
|
|
81
|
+
declared.add(name);
|
|
82
|
+
}
|
|
83
|
+
return Object.keys(kitDeps.packages)
|
|
84
|
+
.filter((name) => !declared.has(name))
|
|
85
|
+
.sort()
|
|
86
|
+
.map((name) => `${name}@${kitDeps.packages[name]}`);
|
|
87
|
+
}
|
|
88
|
+
/** The repo's package manager, by its lockfile — pure over `exists`. */
|
|
89
|
+
export function detectPackageManager(rootDir, exists = existsSync) {
|
|
90
|
+
return PM_LOCKFILES.find(([file]) => exists(path.join(rootDir, file)))?.[1] ?? 'pnpm';
|
|
91
|
+
}
|
|
92
|
+
/** `<pm>` + its args to add `specs` as devDependencies. */
|
|
93
|
+
export function installArgs(pm, specs) {
|
|
94
|
+
return [pm, [...PM_ADD_DEV[pm], ...specs]];
|
|
95
|
+
}
|
|
96
|
+
/** The command as a dev types it: each spec quoted when the shell could read it (`^` in zsh). */
|
|
97
|
+
export function installCommand(pm, specs) {
|
|
98
|
+
const [bin, args] = installArgs(pm, specs);
|
|
99
|
+
const quote = (arg) => (/^[A-Za-z0-9@/._=-]+$/.test(arg) ? arg : `'${arg}'`);
|
|
100
|
+
return [bin, ...args.map(quote)].join(' ');
|
|
101
|
+
}
|
|
102
|
+
async function defaultConfirm(question) {
|
|
103
|
+
const answer = (await prompts({
|
|
104
|
+
type: 'confirm',
|
|
105
|
+
name: 'ok',
|
|
106
|
+
message: question,
|
|
107
|
+
initial: true,
|
|
108
|
+
}));
|
|
109
|
+
return answer?.ok === true;
|
|
110
|
+
}
|
|
111
|
+
async function defaultRun(bin, args, cwd) {
|
|
112
|
+
const result = await execa(bin, args, { cwd, stdio: 'inherit', reject: false });
|
|
113
|
+
return result.exitCode ?? 1;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Compare the kit's list with the repo's `package.json` and offer to install what is
|
|
117
|
+
* missing (see the module doc for who gets asked). Never throws for a missing or invalid
|
|
118
|
+
* `package.json` — the brain is installed either way.
|
|
119
|
+
*/
|
|
120
|
+
export async function offerKitDeps(rootDir, kitDeps, opts = {}) {
|
|
121
|
+
const pkgPath = path.join(rootDir, PACKAGE_JSON_FILE);
|
|
122
|
+
if (kitDeps === null || !existsSync(pkgPath))
|
|
123
|
+
return { action: 'none', missing: [] };
|
|
124
|
+
let pkg;
|
|
125
|
+
try {
|
|
126
|
+
pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return { action: 'none', missing: [] };
|
|
130
|
+
}
|
|
131
|
+
const missing = missingKitDeps(pkg, kitDeps);
|
|
132
|
+
if (missing.length === 0)
|
|
133
|
+
return { action: 'none', missing };
|
|
134
|
+
const pm = detectPackageManager(rootDir);
|
|
135
|
+
const command = installCommand(pm, missing);
|
|
136
|
+
console.log(`\nLos archivos del kit usan ${missing.length} paquete(s) que tu \`package.json\` no declara ` +
|
|
137
|
+
'(sin ellos el typecheck, el build o el CI pueden fallar):');
|
|
138
|
+
for (const spec of missing)
|
|
139
|
+
console.log(` • ${spec}`);
|
|
140
|
+
if (opts.dryRun) {
|
|
141
|
+
console.log(`Para instalarlos: \`${command}\``);
|
|
142
|
+
return { action: 'listed', missing };
|
|
143
|
+
}
|
|
144
|
+
const isTTY = opts.isTTY ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
145
|
+
if (opts.assumeYes || !isTTY) {
|
|
146
|
+
console.log(`Instálalos con: \`${command}\``);
|
|
147
|
+
return { action: 'printed', missing };
|
|
148
|
+
}
|
|
149
|
+
const confirm = opts.confirm ?? defaultConfirm;
|
|
150
|
+
if (!(await confirm(`¿Los instalo ahora como devDependencies? (\`${command}\`)`))) {
|
|
151
|
+
console.log(`No instalé nada. Cuando quieras: \`${command}\``);
|
|
152
|
+
return { action: 'declined', missing };
|
|
153
|
+
}
|
|
154
|
+
const [bin, args] = installArgs(pm, missing);
|
|
155
|
+
const exitCode = await (opts.run ?? defaultRun)(bin, args, rootDir);
|
|
156
|
+
if (exitCode !== 0) {
|
|
157
|
+
console.warn(`Aviso: \`${command}\` falló (código ${exitCode}). Revísalo y repítelo a mano.`);
|
|
158
|
+
return { action: 'failed', missing };
|
|
159
|
+
}
|
|
160
|
+
console.log(`✔ Paquetes instalados. \`package.json\` y el lockfile de \`${pm}\` quedaron modificados ` +
|
|
161
|
+
'y sin commitear: revísalos y commitéalos tú.');
|
|
162
|
+
return { action: 'installed', missing };
|
|
163
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"473a92392f7357596cf877cf4703a49ee03786141313e0959734c8c537561006": "0c2922a8fce7c8073d3740392caaedbdd1b502efd0da07b988fca271dd32bc93",
|
|
3
|
+
"ac139661924928e1080053b6032409c6b105f5c929355ce95ff5a91ee03cd100": "f24cc44dd20bb99edf5511e1202948fe3fe35ee063e283790eaabdf07eac6cee",
|
|
4
|
+
"9784f61835263225d26c4f2d5646e9540597667173510e4865c64439aff26950": "b582d978287acf77175fdc0048d23771da7b8c6f04d519eb98253d6b726d624a",
|
|
5
|
+
"04664118924eb3a936fff0c37818befe0b950e8cc5fc8ced04917d4575b1dae8": "69fc8443158543232b07c6e0177a4630b25287f7b298811192ec5ecc65586fa9",
|
|
6
|
+
"a2fd91e21cf3110ad7c59b54b209ae2228e9797e16ef346e040af01dd640f1ef": "d1f75b3591fc12ac29c670183376cd1a1c79f30149be0aa28c5e412a6879227b",
|
|
7
|
+
"0b834d5e7f2e14d2d617b665bd768b89876d2bd8e3f17244a03194c02d03c528": "7ad35befedc2260fb75df0d6bd4c42720c478e02f97f1d43e23769fd7516da78",
|
|
8
|
+
"2ab69db4fcab8cd10637b41004503caaedf30855af7779bc19cc85ea39f9b62a": "49cb2ac5d07b69fa9d35c7ab97acd23a311c0fe76cbacd74e137e7dce5300ef2",
|
|
9
|
+
"8ab33214c9204aebfb655a9f8b44fdf14e3d6e553b6142014f54b441400b82af": "2cc1afb006290a1ddf88b43b9fdc9f12354f1c22c5983c52a9d47c796a0c44ff",
|
|
10
|
+
"6248c3c08b75455d5e0af8fd38089ac48a501ed851d4982b1fb2bbbade76f8ed": "bc50ff7698b6e7f9573649015295a58b3667d3eed72e8369f1178b3f2d553b53",
|
|
11
|
+
"62f90996cf30610266fc95b8be85ac6e79b2156d03083415dc7c063b9efe4205": "d48522be6e87f07ae60c8cd26bda469014d0fe15632f3ff8a0a50562a58d44d9",
|
|
12
|
+
"5a11e07728b61229a0cbcc5f9b31daf8c71e8c701f5daf4b026328226481d167": "92ad1c42bd0f3f4e93ba9a3ff358d084083ad87acc8719dfc7211edd76c30348"
|
|
13
|
+
}
|
package/dist/lib/lockfile.js
CHANGED
|
@@ -32,7 +32,7 @@ import { createHash } from 'node:crypto';
|
|
|
32
32
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
33
33
|
import path from 'node:path';
|
|
34
34
|
import { CLIError } from './cli-error.js';
|
|
35
|
-
import { CLAUDE_MD_FILE, LOCKFILE_FILE, MANIFEST_FILE, TIMEKAST_DIR, } from './constants.js';
|
|
35
|
+
import { CLAUDE_MD_FILE, KIT_DEPS_FILE, LOCKFILE_FILE, MANIFEST_FILE, TIMEKAST_DIR, } from './constants.js';
|
|
36
36
|
/**
|
|
37
37
|
* Write the initial lockfile in `destDir/.timekast/lockfile.json` (used by
|
|
38
38
|
* `new` / `add`). On the initial install the lockfile carries the tarball's
|
|
@@ -54,9 +54,11 @@ export function writeInitialLockfile(destDir, manifestRaw) {
|
|
|
54
54
|
const stamped = { ...manifest, factoryVersion: manifest.version };
|
|
55
55
|
// If the tarball already unpacked manifest.json into .timekast/, drop it: the
|
|
56
56
|
// stamped lockfile is the SSOT and we do not keep a duplicate manifest around.
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
57
|
+
// Same for the dependency list (Factory#78): it is read from the staged tarball, never kept.
|
|
58
|
+
for (const unpacked of [MANIFEST_FILE, KIT_DEPS_FILE]) {
|
|
59
|
+
const abs = path.join(timekastDir, unpacked);
|
|
60
|
+
if (existsSync(abs))
|
|
61
|
+
rmSync(abs, { force: true });
|
|
60
62
|
}
|
|
61
63
|
writeLockfile(destDir, stamped);
|
|
62
64
|
}
|
|
@@ -124,7 +126,10 @@ export function parseLockfile(raw, source = 'lockfile') {
|
|
|
124
126
|
if (typeof e.path !== 'string' || typeof e.hash !== 'string') {
|
|
125
127
|
throw new CLIError(`El ${source} tiene una entrada sin \`path\`/\`hash\` en files[${i}].`);
|
|
126
128
|
}
|
|
127
|
-
|
|
129
|
+
const keys = Array.isArray(e.keys) && e.keys.every((k) => typeof k === 'string')
|
|
130
|
+
? { keys: e.keys }
|
|
131
|
+
: {};
|
|
132
|
+
return { path: e.path, hash: e.hash, ...keys };
|
|
128
133
|
});
|
|
129
134
|
return {
|
|
130
135
|
version: obj.version,
|
package/dist/lib/prune-remote.js
CHANGED
|
@@ -391,11 +391,13 @@ async function listNotMigrated(legacy, deps) {
|
|
|
391
391
|
}
|
|
392
392
|
return out;
|
|
393
393
|
}
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
394
|
+
/**
|
|
395
|
+
* Invite an outside collaborator, after checking they are not a member. Write (`push`) by
|
|
396
|
+
* default: collaborators work on branches of this repo and open PRs that `/integrate` checks.
|
|
397
|
+
* On GitHub Free a private repo cannot protect `main`, so write also reaches it directly —
|
|
398
|
+
* the owner's call, accepted for in-house devs; `--read` grants read only.
|
|
399
|
+
*/
|
|
400
|
+
export async function pruneInvite(repo, user, deps, permission = 'push') {
|
|
399
401
|
const { owner } = splitRepo(repo);
|
|
400
402
|
if (!/^[A-Za-z0-9-]+$/.test(user))
|
|
401
403
|
throw new CLIError(`\`${user}\` no es un usuario de GitHub válido.`);
|
|
@@ -412,7 +414,7 @@ export async function pruneInvite(repo, user, deps) {
|
|
|
412
414
|
'PUT',
|
|
413
415
|
`repos/${repo}/collaborators/${user}`,
|
|
414
416
|
'-f',
|
|
415
|
-
|
|
417
|
+
`permission=${permission}`,
|
|
416
418
|
]);
|
|
417
419
|
if (r.exitCode !== 0) {
|
|
418
420
|
if (/403|Forbidden|must have admin/i.test(r.stderr)) {
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `factory prune vercel` — reconnect the Vercel project to the repo the cut created.
|
|
3
|
+
*
|
|
4
|
+
* Vercel binds a project to GitHub's NUMERIC repo id, and a rename keeps the id: after
|
|
5
|
+
* `prune cutover` the project still deploys from `<repo>-legacy`. Re-linking to the new
|
|
6
|
+
* repo takes an unlink + link (`DELETE` then `POST /v9/projects/{id}/link`); the project,
|
|
7
|
+
* its env vars, domains and deployments stay.
|
|
8
|
+
*
|
|
9
|
+
* Order is the safety argument:
|
|
10
|
+
* 1. read the current link and the new repo's id — already linked → nothing to do;
|
|
11
|
+
* 2. without `--confirm`, print the plan and stop;
|
|
12
|
+
* 3. unlink → link → READ the link back and require the new repo's id.
|
|
13
|
+
* A failure between 1 and 3 leaves the project unlinked (it serves its last deployment, it
|
|
14
|
+
* just stops deploying on push); a re-run with `--confirm` links it, since step 1 sees no link.
|
|
15
|
+
* Never triggers a deployment.
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { CLIError } from './cli-error.js';
|
|
20
|
+
import { readDestroyTarget } from './provision-state.js';
|
|
21
|
+
import { getGitLink, linkGitRepo, unlinkGitRepo, } from './vercel-env.js';
|
|
22
|
+
/** GitHub's numeric id of `repo`, read with the member's `gh` session. */
|
|
23
|
+
async function githubRepoId(repo, deps) {
|
|
24
|
+
const r = await deps.gh(['api', `repos/${repo}`, '--jq', '.id']);
|
|
25
|
+
const id = Number(r.stdout.trim());
|
|
26
|
+
if (r.exitCode !== 0 || !Number.isInteger(id)) {
|
|
27
|
+
throw new CLIError(`No pude leer \`${repo}\` en GitHub: ${r.stderr || 'respuesta sin id'}`);
|
|
28
|
+
}
|
|
29
|
+
return id;
|
|
30
|
+
}
|
|
31
|
+
export async function pruneVercelRelink(input, deps) {
|
|
32
|
+
const { projectId, teamId, token } = input;
|
|
33
|
+
const api = deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {};
|
|
34
|
+
const targetId = await githubRepoId(input.repo, deps);
|
|
35
|
+
const from = await getGitLink(projectId, token, teamId, api);
|
|
36
|
+
if (from?.repoId === targetId)
|
|
37
|
+
return { status: 'already-linked', link: from };
|
|
38
|
+
if (!input.confirm)
|
|
39
|
+
return { status: 'plan', from, to: input.repo };
|
|
40
|
+
if (from)
|
|
41
|
+
await unlinkGitRepo(projectId, token, teamId, api);
|
|
42
|
+
try {
|
|
43
|
+
await linkGitRepo(projectId, token, teamId, input.repo, api);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
const why = error instanceof Error ? error.message : String(error);
|
|
47
|
+
throw new CLIError(`Desconecté el proyecto de Vercel${from ? ` de \`${from.repo}\`` : ''} pero no pude conectarlo a ` +
|
|
48
|
+
`\`${input.repo}\`: ${why}\nEl proyecto sigue sirviendo su último deploy; sólo dejó de desplegar ` +
|
|
49
|
+
'con cada push. Corrige la causa (p. ej. la GitHub App de Vercel sin acceso al repo nuevo) y ' +
|
|
50
|
+
'vuelve a correr `factory prune vercel --confirm`: ve que no hay vínculo y sólo conecta.');
|
|
51
|
+
}
|
|
52
|
+
const to = await getGitLink(projectId, token, teamId, api);
|
|
53
|
+
if (to?.repoId !== targetId) {
|
|
54
|
+
throw new CLIError(`Vercel aceptó el vínculo pero al releerlo apunta a ${to ? `\`${to.repo}\`` : 'ningún repo'}, no a ` +
|
|
55
|
+
`\`${input.repo}\`. Revísalo en el dashboard (Settings → Git) antes de seguir.`);
|
|
56
|
+
}
|
|
57
|
+
return { status: 'relinked', from, to };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The project and team to relink: flags first, then `.vercel/project.json`, then the provision
|
|
61
|
+
* state (`.timekast/provision.json`). Never guessed from a project listing.
|
|
62
|
+
*/
|
|
63
|
+
export function resolveVercelProject(rootDir, flags) {
|
|
64
|
+
let projectId = flags.project;
|
|
65
|
+
let teamId = flags.team;
|
|
66
|
+
const linkFile = path.join(rootDir, '.vercel', 'project.json');
|
|
67
|
+
if ((!projectId || !teamId) && existsSync(linkFile)) {
|
|
68
|
+
try {
|
|
69
|
+
const link = JSON.parse(readFileSync(linkFile, 'utf8'));
|
|
70
|
+
projectId ??= link.projectId;
|
|
71
|
+
teamId ??= link.teamId ?? link.orgId;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// unreadable → fall through to the provision state
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (!projectId || !teamId) {
|
|
78
|
+
const target = readDestroyTarget(rootDir);
|
|
79
|
+
projectId ??= target?.vercelProjectId ?? undefined;
|
|
80
|
+
teamId ??= target?.scope ?? undefined;
|
|
81
|
+
}
|
|
82
|
+
if (!projectId || !teamId) {
|
|
83
|
+
throw new CLIError('No encontré el proyecto de Vercel de este repo (ni en `.vercel/project.json` ni en ' +
|
|
84
|
+
'`.timekast/provision.json`). Pásalo: `factory prune vercel --project=prj_… --team=team_…`.');
|
|
85
|
+
}
|
|
86
|
+
return { projectId, teamId };
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Which branches deploy on push in a pruned repo: only `main` and `develop`. Vercel deploys
|
|
90
|
+
* when ANY matching rule is `true`, so `**: false` turns off every other branch — a
|
|
91
|
+
* collaborator's branch (an author without a Vercel seat) never starts a deployment Vercel
|
|
92
|
+
* would block. `**`, not `*`: minimatch's `*` does not cross a `/` (`feat/login`).
|
|
93
|
+
*/
|
|
94
|
+
export const BRANCH_DEPLOY_RULE = {
|
|
95
|
+
'**': false,
|
|
96
|
+
main: true,
|
|
97
|
+
develop: true,
|
|
98
|
+
};
|
|
99
|
+
export const VERCEL_JSON = 'vercel.json';
|
|
100
|
+
/** Whether `vercel.json` already carries {@link BRANCH_DEPLOY_RULE}. `null` without the file. */
|
|
101
|
+
export function hasBranchDeployRule(rootDir) {
|
|
102
|
+
const file = path.join(rootDir, VERCEL_JSON);
|
|
103
|
+
if (!existsSync(file))
|
|
104
|
+
return null;
|
|
105
|
+
const config = JSON.parse(readFileSync(file, 'utf8'));
|
|
106
|
+
const rules = config.git?.deploymentEnabled;
|
|
107
|
+
if (!rules || typeof rules !== 'object')
|
|
108
|
+
return false;
|
|
109
|
+
return Object.entries(BRANCH_DEPLOY_RULE).every(([branch, on]) => rules[branch] === on);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Write {@link BRANCH_DEPLOY_RULE} into `vercel.json`, keeping every other key and any other
|
|
113
|
+
* branch rule. A repo without `vercel.json` (not on Vercel) is left alone.
|
|
114
|
+
*
|
|
115
|
+
* @throws {CLIError} when `vercel.json` is not valid JSON.
|
|
116
|
+
*/
|
|
117
|
+
export function ensureBranchDeployRule(rootDir) {
|
|
118
|
+
const file = path.join(rootDir, VERCEL_JSON);
|
|
119
|
+
if (!existsSync(file))
|
|
120
|
+
return 'no-vercel-json';
|
|
121
|
+
let config;
|
|
122
|
+
try {
|
|
123
|
+
config = JSON.parse(readFileSync(file, 'utf8'));
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
throw new CLIError(`\`${VERCEL_JSON}\` no es JSON válido; corrígelo y vuelve a correr.`);
|
|
127
|
+
}
|
|
128
|
+
if (hasBranchDeployRule(rootDir))
|
|
129
|
+
return 'present';
|
|
130
|
+
const git = (config.git && typeof config.git === 'object' ? config.git : {});
|
|
131
|
+
const prior = git.deploymentEnabled && typeof git.deploymentEnabled === 'object'
|
|
132
|
+
? git.deploymentEnabled
|
|
133
|
+
: {};
|
|
134
|
+
config.git = { ...git, deploymentEnabled: { ...prior, ...BRANCH_DEPLOY_RULE } };
|
|
135
|
+
writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`);
|
|
136
|
+
return 'added';
|
|
137
|
+
}
|
package/dist/lib/rail.js
CHANGED
|
@@ -53,8 +53,10 @@ import { VAULT_CONFIG } from './vault-config.js';
|
|
|
53
53
|
export const RAIL_ENV = 'main';
|
|
54
54
|
/** Name of the vault CLI binary on PATH. */
|
|
55
55
|
export const INFISICAL_BIN = 'infisical';
|
|
56
|
-
/** How to install the vault CLI (`
|
|
57
|
-
export const INFISICAL_INSTALL_HINT = 'brew install infisical';
|
|
56
|
+
/** How to install the vault CLI, per platform, already code-quoted (`sk-vault §2`). */
|
|
57
|
+
export const INFISICAL_INSTALL_HINT = '`brew install infisical` (Windows: `winget install --id infisical.infisical` y abre una terminal nueva)';
|
|
58
|
+
/** How to upgrade the vault CLI, per platform, already code-quoted. */
|
|
59
|
+
export const INFISICAL_UPGRADE_HINT = '`brew upgrade infisical` o `winget upgrade --id infisical.infisical`';
|
|
58
60
|
/** Default time budget (ms) for one rail read — a dead network or a stuck prompt must not hang. */
|
|
59
61
|
export const RAIL_READ_TIMEOUT_MS = 30_000;
|
|
60
62
|
/** Grace (ms) between SIGTERM and SIGKILL once the timeout fires. */
|
|
@@ -135,7 +137,7 @@ export class RailUnrecognizedError extends RailError {
|
|
|
135
137
|
export class RailNotInstalledError extends RailError {
|
|
136
138
|
constructor(config = VAULT_CONFIG) {
|
|
137
139
|
super('not-installed', 'El CLI de la bóveda (`infisical`) no está instalado o no está en el PATH.\n' +
|
|
138
|
-
`Instálalo con
|
|
140
|
+
`Instálalo con ${INFISICAL_INSTALL_HINT} y entra con \`${loginCommand(config)}\`.`);
|
|
139
141
|
this.name = 'RailNotInstalledError';
|
|
140
142
|
Object.setPrototypeOf(this, new.target.prototype);
|
|
141
143
|
}
|
package/dist/lib/railway-api.js
CHANGED
|
@@ -744,6 +744,9 @@ export function createRailwayClient(deps = {}) {
|
|
|
744
744
|
if (typeof settings[key] === 'string')
|
|
745
745
|
input[key] = settings[key];
|
|
746
746
|
}
|
|
747
|
+
if (typeof settings.drainingSeconds === 'number') {
|
|
748
|
+
input.drainingSeconds = settings.drainingSeconds;
|
|
749
|
+
}
|
|
747
750
|
if (Object.keys(input).length === 0)
|
|
748
751
|
return false;
|
|
749
752
|
await call('serviceInstanceUpdate', { serviceId, environmentId, input }, `ajustar el servicio \`${serviceId}\` en su entorno`);
|