@timekast/factory 2.4.0 → 2.6.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 +37 -8
- package/dist/commands/env.js +340 -8
- package/dist/commands/provision.js +327 -42
- package/dist/commands/prune.js +60 -10
- package/dist/commands/update.js +109 -85
- package/dist/commands/usage.js +303 -0
- package/dist/commands/vault.js +81 -18
- package/dist/index.js +52 -3
- 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/env-file.js +4 -0
- package/dist/lib/env-wizard.js +1 -1
- 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/public-env-keys.json +2 -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 +29 -0
- package/dist/lib/whats-new.js +123 -0
- package/package.json +1 -1
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
|
+
}
|
|
@@ -13,10 +13,12 @@
|
|
|
13
13
|
"EMAIL_SERVER_PORT",
|
|
14
14
|
"EMAIL_SERVER_SECURE",
|
|
15
15
|
"EMAIL_SERVER_USER",
|
|
16
|
+
"MCP_ENABLED",
|
|
16
17
|
"MFA_ENABLED",
|
|
17
18
|
"MFA_REQUIRED_ALL",
|
|
18
19
|
"NEXTAUTH_URL",
|
|
19
20
|
"NODE_ENV",
|
|
21
|
+
"QUESTIONS_ENABLED",
|
|
20
22
|
"SENTRY_ORG",
|
|
21
23
|
"SENTRY_PROJECT",
|
|
22
24
|
"SUPPORT_EMAIL",
|
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`);
|