@timekast/factory 0.1.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 +78 -0
- package/dist/commands/doctor.js +127 -0
- package/dist/commands/new.js +128 -0
- package/dist/commands/status.js +148 -0
- package/dist/commands/update.js +344 -0
- package/dist/index.js +121 -0
- package/dist/lib/atomic-swap.js +160 -0
- package/dist/lib/cli-error.js +24 -0
- package/dist/lib/constants.js +24 -0
- package/dist/lib/lockfile.js +282 -0
- package/dist/lib/package-json.js +90 -0
- package/dist/lib/preflight.js +79 -0
- package/dist/lib/repo-detection.js +32 -0
- package/dist/lib/unpack.js +86 -0
- package/dist/lib/validate-name.js +34 -0
- package/package.json +51 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `factory add` — install the `core` brain into an existing repo (design §6.2).
|
|
3
|
+
*
|
|
4
|
+
* Flow: detect a git repo (cwd or ancestor) → refuse with an actionable message
|
|
5
|
+
* if none → preflight (gh) → download the `core` profile (literal, never `full`)
|
|
6
|
+
* → extract to temp + validate the embedded manifest → move the staged contents
|
|
7
|
+
* into the cwd (only the manifest's files) → write `.timekast/lockfile.json`.
|
|
8
|
+
*
|
|
9
|
+
* Invariants:
|
|
10
|
+
* - `add` ONLY ever installs `core`. The profile is the literal `PROFILES.core`,
|
|
11
|
+
* never a flag — `full` is structurally unreachable from this command.
|
|
12
|
+
* - `add` never touches `src/`, `package.json`, `pj-*`, or any file outside the
|
|
13
|
+
* `core` manifest. The `core` tarball contains only `core` files, so moving
|
|
14
|
+
* its contents in cannot reach dev-owned paths; files in the cwd that are not
|
|
15
|
+
* in the tarball are left byte-identical.
|
|
16
|
+
* - `add` overwrites manifest files without prompting — the conflict prompt is
|
|
17
|
+
* exclusive to `update` (DIST-005). Documented in the command's `--help`.
|
|
18
|
+
* - Atomicity (§7.5): extract + validate in a temp dir before touching the cwd;
|
|
19
|
+
* a failure mid-flight never leaves a partial `.claude/`.
|
|
20
|
+
*
|
|
21
|
+
* Expected failures throw `CLIError`; the top-level handler prints + exits.
|
|
22
|
+
*/
|
|
23
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
24
|
+
import { tmpdir } from 'node:os';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import { CLIError } from '../lib/cli-error.js';
|
|
27
|
+
import { PROFILES } from '../lib/constants.js';
|
|
28
|
+
import { writeInitialLockfile } from '../lib/lockfile.js';
|
|
29
|
+
import { runPreflight } from '../lib/preflight.js';
|
|
30
|
+
import { detectRepo } from '../lib/repo-detection.js';
|
|
31
|
+
import { downloadProfileTarball, moveContentsInto, stageProfileTarball } from '../lib/unpack.js';
|
|
32
|
+
/** Message shown when `add` runs outside any git repo context. */
|
|
33
|
+
const NO_REPO_MESSAGE = 'No hay repositorio git aquí. Ejecuta `git init` primero, o usa `factory new` para crear un proyecto desde cero.';
|
|
34
|
+
export async function runAdd() {
|
|
35
|
+
const cwd = process.cwd();
|
|
36
|
+
// 1. Require a git repo context (cwd or any ancestor). Refuse before any
|
|
37
|
+
// network call or write — nothing is touched when there is no repo.
|
|
38
|
+
const { hasRepo } = detectRepo(cwd);
|
|
39
|
+
if (!hasRepo) {
|
|
40
|
+
throw new CLIError(NO_REPO_MESSAGE);
|
|
41
|
+
}
|
|
42
|
+
// 2. Preflight: gh installed + authed + org member. Runs before any download.
|
|
43
|
+
await runPreflight();
|
|
44
|
+
// 3. Download + unpack into a temp dir, validate, then move into place (§7.5).
|
|
45
|
+
const tmpDir = mkdtempSync(path.join(tmpdir(), 'tk-add-'));
|
|
46
|
+
const cleanup = () => {
|
|
47
|
+
try {
|
|
48
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
/* best effort */
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
const onSignal = () => {
|
|
55
|
+
cleanup();
|
|
56
|
+
process.exit(130);
|
|
57
|
+
};
|
|
58
|
+
process.once('SIGINT', onSignal);
|
|
59
|
+
process.once('SIGTERM', onSignal);
|
|
60
|
+
try {
|
|
61
|
+
// `add` is structurally `core`-only: the literal is passed, never a flag.
|
|
62
|
+
const tarball = await downloadProfileTarball(PROFILES.core, tmpDir);
|
|
63
|
+
// Extract + validate the embedded manifest before touching the destination.
|
|
64
|
+
const { stagedDir, manifestRaw } = await stageProfileTarball(tarball, tmpDir);
|
|
65
|
+
// Move only the tarball's (core) contents into the cwd. Files outside the
|
|
66
|
+
// manifest — src/, package.json, pj-* — are never visited, so they stay
|
|
67
|
+
// byte-identical.
|
|
68
|
+
moveContentsInto(stagedDir, cwd);
|
|
69
|
+
// Write the lockfile = the tarball's embedded manifest (no hash recompute).
|
|
70
|
+
writeInitialLockfile(cwd, manifestRaw);
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
process.removeListener('SIGINT', onSignal);
|
|
74
|
+
process.removeListener('SIGTERM', onSignal);
|
|
75
|
+
cleanup();
|
|
76
|
+
}
|
|
77
|
+
console.log('\n✔ Cerebro `core` instalado. Lockfile escrito en `.timekast/lockfile.json`.');
|
|
78
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `factory doctor` — diagnose the derived project's brain (design §6.4):
|
|
3
|
+
*
|
|
4
|
+
* - ORPHANS: files under `.claude/` of the derived repo that are NOT in the
|
|
5
|
+
* installed manifest (the lockfile). Either dev-owned (`pj-*`) or kit files
|
|
6
|
+
* retired since birth — surfaced so the dev can decide; never auto-deleted.
|
|
7
|
+
* - PENDING CONFLICTS: tracked files whose on-disk hash differs from the hash
|
|
8
|
+
* recorded in the lockfile (`normalizeThenHash` reused from lockfile.ts, the
|
|
9
|
+
* SAME normalization the builder + update engine use — no divergence). These
|
|
10
|
+
* are local modifications not yet reconciled via `update`.
|
|
11
|
+
* - A1 WARNING: a literal security notice (design §1 + §11, decision A1) that
|
|
12
|
+
* `src/` + boilerplate deps are the derived project's responsibility
|
|
13
|
+
* post-bootstrap, and that the patch channel for `src/` is EPIC 2 (agentic
|
|
14
|
+
* update, not yet available).
|
|
15
|
+
*
|
|
16
|
+
* No lockfile (pre-CLI repo) → delegate to the "run factory:update" advice, exit
|
|
17
|
+
* 0, never abort (same pattern as `status` / the §8 edge case).
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { hasLockfile, normalizeThenHash, readLockfile } from '../lib/lockfile.js';
|
|
22
|
+
/**
|
|
23
|
+
* The A1 security notice (design §1 + §11, decision A1). Literal text emitted at
|
|
24
|
+
* the end of every `doctor` run so the dev always sees the `src/` responsibility
|
|
25
|
+
* boundary. Exported so tests can assert the rendered output contains it.
|
|
26
|
+
*/
|
|
27
|
+
export const A1_WARNING = 'Aviso de seguridad (A1): el boilerplate `src/` y sus dependencias (Next.js, ' +
|
|
28
|
+
'NextAuth, Drizzle, etc.) quedan congelados en el momento del bootstrap y son ' +
|
|
29
|
+
'responsabilidad del proyecto derivado a partir de ahí. `factory:update` NO ' +
|
|
30
|
+
'actualiza `src/` ni las dependencias: solo refresca el cerebro (`.claude/`). ' +
|
|
31
|
+
'Un parche de seguridad para `src/` (un CVE en auth, middleware, headers o una ' +
|
|
32
|
+
'dependencia) NO llega por este canal. El canal de parches de `src/` es EPIC 2 ' +
|
|
33
|
+
'(update agentico con merge inteligente), aún no disponible. Mantén `src/` y tus ' +
|
|
34
|
+
'dependencias al día por tu cuenta mientras tanto.';
|
|
35
|
+
/**
|
|
36
|
+
* Recursively collect repo-relative POSIX paths of files under `.claude/`. Only
|
|
37
|
+
* descends into `.claude/`; `src/` and everything else is invisible (design
|
|
38
|
+
* §7.2). Mirrors the `update` engine's `collectClaudePaths`.
|
|
39
|
+
*/
|
|
40
|
+
function collectClaudePaths(rootDir) {
|
|
41
|
+
const out = [];
|
|
42
|
+
const walk = (rel) => {
|
|
43
|
+
const abs = path.join(rootDir, rel);
|
|
44
|
+
if (!existsSync(abs))
|
|
45
|
+
return;
|
|
46
|
+
if (statSync(abs).isDirectory()) {
|
|
47
|
+
for (const entry of readdirSync(abs)) {
|
|
48
|
+
walk(path.posix.join(rel, entry));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
out.push(rel);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
walk('.claude');
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Compute the doctor diagnosis. No lockfile → `noLockfile: true` (the caller
|
|
60
|
+
* renders the "run factory:update" advice). Otherwise diffs the on-disk
|
|
61
|
+
* `.claude/` tree against the lockfile:
|
|
62
|
+
* - orphan = `.claude/` path on disk, not in the manifest.
|
|
63
|
+
* - conflict = tracked path whose normalized disk hash != recorded hash.
|
|
64
|
+
*/
|
|
65
|
+
export function computeDoctor(deps = {}) {
|
|
66
|
+
const rootDir = deps.rootDir ?? process.cwd();
|
|
67
|
+
if (!hasLockfile(rootDir)) {
|
|
68
|
+
return { orphans: [], conflicts: [], a1Warning: A1_WARNING, noLockfile: true };
|
|
69
|
+
}
|
|
70
|
+
const lock = readLockfile(rootDir);
|
|
71
|
+
const manifestPaths = new Set(lock.files.map((f) => f.path));
|
|
72
|
+
// Orphans: files under `.claude/` on disk that the manifest does not track.
|
|
73
|
+
const claudePaths = collectClaudePaths(rootDir);
|
|
74
|
+
const orphans = claudePaths.filter((p) => !manifestPaths.has(p)).sort();
|
|
75
|
+
// Pending conflicts: tracked file whose normalized on-disk hash drifted from
|
|
76
|
+
// the recorded hash (a local modification not yet resolved by `update`).
|
|
77
|
+
const conflicts = [];
|
|
78
|
+
for (const entry of lock.files) {
|
|
79
|
+
const abs = path.join(rootDir, entry.path);
|
|
80
|
+
if (!existsSync(abs) || !statSync(abs).isFile())
|
|
81
|
+
continue;
|
|
82
|
+
const localHash = normalizeThenHash(readFileSync(abs, 'utf8'));
|
|
83
|
+
if (localHash !== entry.hash) {
|
|
84
|
+
conflicts.push(entry.path);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
conflicts.sort();
|
|
88
|
+
return { orphans, conflicts, a1Warning: A1_WARNING, noLockfile: false };
|
|
89
|
+
}
|
|
90
|
+
/** Render the doctor result to the console. The A1 notice is ALWAYS last. */
|
|
91
|
+
function renderDoctor(result) {
|
|
92
|
+
if (result.noLockfile) {
|
|
93
|
+
console.log('Sin lockfile detectado.');
|
|
94
|
+
console.log('Ejecuta `factory:update` para inicializar el tracking de versión.');
|
|
95
|
+
// The A1 notice is emitted even without a lockfile (always present).
|
|
96
|
+
console.log(`\n${result.a1Warning}`);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (result.orphans.length === 0 && result.conflicts.length === 0) {
|
|
100
|
+
console.log('✔ Sin huérfanos ni conflictos pendientes.');
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
if (result.conflicts.length > 0) {
|
|
104
|
+
console.log('Conflictos pendientes (modificado local + Factory actualizó):');
|
|
105
|
+
for (const p of result.conflicts) {
|
|
106
|
+
console.log(` • ${p} — conflicto pendiente (modificado local + Factory actualizó)`);
|
|
107
|
+
}
|
|
108
|
+
console.log(' Sugerencia: corre `factory:update --verify` para re-chequear, o `factory:update` para reconciliar.');
|
|
109
|
+
}
|
|
110
|
+
if (result.orphans.length > 0) {
|
|
111
|
+
console.log('\nArchivos huérfanos (en `.claude/` fuera del manifest instalado):');
|
|
112
|
+
for (const p of result.orphans) {
|
|
113
|
+
console.log(` • ${p} — huérfano (local del dev o retirado por el Factory)`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// The A1 security notice is always emitted at the end (design §1 + §11).
|
|
118
|
+
console.log(`\n${result.a1Warning}`);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Run `factory doctor`. Always exits 0 — a missing lockfile is advisory, not a
|
|
122
|
+
* failure (DIST-006 §8). The A1 notice is emitted on every run.
|
|
123
|
+
*/
|
|
124
|
+
export function runDoctor(deps = {}) {
|
|
125
|
+
const result = computeDoctor(deps);
|
|
126
|
+
renderDoctor(result);
|
|
127
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `factory new <Name>` — bootstrap a brand-new derived project (design §6.1).
|
|
3
|
+
*
|
|
4
|
+
* Flow: validate name → preflight (gh) → refuse if a repo already exists →
|
|
5
|
+
* confirm if the cwd is non-empty → choose profile (full | core) → create the
|
|
6
|
+
* GitHub repo → download the profile tarball → unpack (no Factory `.git/`) →
|
|
7
|
+
* `git init` → rename `package.json.name` → write the initial lockfile →
|
|
8
|
+
* inject the `factory:update` script.
|
|
9
|
+
*
|
|
10
|
+
* Expected failures throw `CLIError`; the top-level handler prints the message
|
|
11
|
+
* and exits with the carried code.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { execa } from 'execa';
|
|
17
|
+
import prompts from 'prompts';
|
|
18
|
+
import { CLIError } from '../lib/cli-error.js';
|
|
19
|
+
import { FACTORY_ORG, PROFILES } from '../lib/constants.js';
|
|
20
|
+
import { writeInitialLockfile } from '../lib/lockfile.js';
|
|
21
|
+
import { applyPackageJsonEdits } from '../lib/package-json.js';
|
|
22
|
+
import { runPreflight } from '../lib/preflight.js';
|
|
23
|
+
import { downloadProfileTarball, moveContentsInto, stageProfileTarball } from '../lib/unpack.js';
|
|
24
|
+
import { validateProjectName } from '../lib/validate-name.js';
|
|
25
|
+
/** Prompt the user to pick a profile (numbered options, checkpoint style). */
|
|
26
|
+
async function promptProfile() {
|
|
27
|
+
const { choice } = await prompts({
|
|
28
|
+
type: 'select',
|
|
29
|
+
name: 'choice',
|
|
30
|
+
message: '¿Qué quieres crear?',
|
|
31
|
+
choices: [
|
|
32
|
+
{ title: 'App completa (boilerplate + cerebro)', value: PROFILES.full },
|
|
33
|
+
{ title: 'Solo el cerebro', value: PROFILES.core },
|
|
34
|
+
],
|
|
35
|
+
initial: 0,
|
|
36
|
+
});
|
|
37
|
+
if (choice === undefined) {
|
|
38
|
+
// User aborted the prompt (Ctrl+C / Esc).
|
|
39
|
+
throw new CLIError('Operación cancelada.', 130);
|
|
40
|
+
}
|
|
41
|
+
return choice;
|
|
42
|
+
}
|
|
43
|
+
/** Confirm continuing into a non-empty (but non-repo) directory. */
|
|
44
|
+
async function confirmNonEmptyDir() {
|
|
45
|
+
const { proceed } = await prompts({
|
|
46
|
+
type: 'confirm',
|
|
47
|
+
name: 'proceed',
|
|
48
|
+
message: 'El directorio actual no está vacío. ¿Deseas continuar de todos modos?',
|
|
49
|
+
initial: false,
|
|
50
|
+
});
|
|
51
|
+
if (!proceed) {
|
|
52
|
+
throw new CLIError('Operación cancelada: el directorio no está vacío.', 130);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** Create the destination GitHub repo, surfacing gh's error cleanly. */
|
|
56
|
+
async function createRepo(name) {
|
|
57
|
+
try {
|
|
58
|
+
await execa('gh', ['repo', 'create', `${FACTORY_ORG}/${name}`, '--private']);
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
const stderr = error?.stderr?.trim();
|
|
62
|
+
const detail = stderr ? `\n${stderr}` : '';
|
|
63
|
+
throw new CLIError(`No se pudo crear el repo \`${FACTORY_ORG}/${name}\` en GitHub.${detail}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
export async function runNew(name) {
|
|
67
|
+
// 1. Validate offline, before any network call.
|
|
68
|
+
const validName = validateProjectName(name);
|
|
69
|
+
// 2. Refuse to run inside an existing repo (before any destructive action).
|
|
70
|
+
const cwd = process.cwd();
|
|
71
|
+
if (existsSync(path.join(cwd, '.git'))) {
|
|
72
|
+
throw new CLIError('ya hay un repo aquí, usa `add`');
|
|
73
|
+
}
|
|
74
|
+
// 3. Preflight: gh installed + authed + org member.
|
|
75
|
+
await runPreflight();
|
|
76
|
+
// 4. Non-empty (but non-repo) directory → confirm instead of aborting.
|
|
77
|
+
const cwdEntries = readdirSync(cwd);
|
|
78
|
+
if (cwdEntries.length > 0) {
|
|
79
|
+
await confirmNonEmptyDir();
|
|
80
|
+
}
|
|
81
|
+
// 5. Choose profile.
|
|
82
|
+
const profile = await promptProfile();
|
|
83
|
+
// 6. Network: create the repo.
|
|
84
|
+
await createRepo(validName);
|
|
85
|
+
// 7. Download + unpack into a temp dir, validate, then move into place (M2).
|
|
86
|
+
const tmpDir = mkdtempSync(path.join(tmpdir(), 'tk-new-'));
|
|
87
|
+
const cleanup = () => {
|
|
88
|
+
try {
|
|
89
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
/* best effort */
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
const onSignal = () => {
|
|
96
|
+
cleanup();
|
|
97
|
+
process.exit(130);
|
|
98
|
+
};
|
|
99
|
+
process.once('SIGINT', onSignal);
|
|
100
|
+
process.once('SIGTERM', onSignal);
|
|
101
|
+
try {
|
|
102
|
+
const tarball = await downloadProfileTarball(profile, tmpDir);
|
|
103
|
+
// Extract + validate the embedded manifest before touching the destination.
|
|
104
|
+
const { stagedDir, manifestRaw } = await stageProfileTarball(tarball, tmpDir);
|
|
105
|
+
// Move contents into cwd (without the Factory's git lineage).
|
|
106
|
+
moveContentsInto(stagedDir, cwd);
|
|
107
|
+
// git init (own lineage).
|
|
108
|
+
await execa('git', ['init'], { cwd });
|
|
109
|
+
// Rename package.json.name + inject factory:update (surgical, §7.4).
|
|
110
|
+
const pkgPath = path.join(cwd, 'package.json');
|
|
111
|
+
if (existsSync(pkgPath)) {
|
|
112
|
+
const { content, scriptAlreadyPresent } = applyPackageJsonEdits(readFileSync(pkgPath, 'utf8'), validName);
|
|
113
|
+
writeFileSync(pkgPath, content, 'utf8');
|
|
114
|
+
if (scriptAlreadyPresent) {
|
|
115
|
+
console.warn('Aviso: `factory:update` ya existía en package.json con otro valor; no se sobrescribió.');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// Write the initial lockfile = the tarball's embedded manifest verbatim
|
|
119
|
+
// (no hash recompute — that's DIST-005).
|
|
120
|
+
writeInitialLockfile(cwd, manifestRaw);
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
process.removeListener('SIGINT', onSignal);
|
|
124
|
+
process.removeListener('SIGTERM', onSignal);
|
|
125
|
+
cleanup();
|
|
126
|
+
}
|
|
127
|
+
console.log(`\n✔ Proyecto \`${validName}\` listo. Repo: ${FACTORY_ORG}/${validName}`);
|
|
128
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `factory status` — report the installed brain version vs. the latest available
|
|
3
|
+
* (design §6.4). Reads `.timekast/lockfile.json` for the installed
|
|
4
|
+
* `agentKitVersion` + `factoryVersion` (birth stamp), and queries the Factory
|
|
5
|
+
* repo's releases for the latest NON-prerelease version.
|
|
6
|
+
*
|
|
7
|
+
* Failure modes are graceful by design (DIST-006 §8 edge cases):
|
|
8
|
+
* - No lockfile (pre-CLI repo, born <10): print "run factory:update" + exit 0,
|
|
9
|
+
* NOT a cryptic error.
|
|
10
|
+
* - Network down / `gh` unavailable when querying the latest release: report the
|
|
11
|
+
* installed version with "(versión remota no disponible — sin conexión)" +
|
|
12
|
+
* exit 0, NOT an unhandled exception.
|
|
13
|
+
*
|
|
14
|
+
* The latest-version query IS the only network seam — tests inject
|
|
15
|
+
* `deps.fetchLatest` so the engine runs with no `gh` and no network.
|
|
16
|
+
*/
|
|
17
|
+
import { execa } from 'execa';
|
|
18
|
+
import { FACTORY_REPO } from '../lib/constants.js';
|
|
19
|
+
import { hasLockfile, readLockfile } from '../lib/lockfile.js';
|
|
20
|
+
/**
|
|
21
|
+
* Query the Factory repo for the latest published release, IGNORING prereleases
|
|
22
|
+
* (`-rc`, `-test`, etc.) so a release candidate is never reported as latest.
|
|
23
|
+
*
|
|
24
|
+
* `gh release list --json` returns each release's `tagName` + `isPrerelease`.
|
|
25
|
+
* The first non-prerelease entry is the latest stable (gh lists newest-first).
|
|
26
|
+
* Any failure (gh missing, not authed, network down) resolves to null — the
|
|
27
|
+
* caller renders the offline path, never throws.
|
|
28
|
+
*/
|
|
29
|
+
async function fetchLatestRelease() {
|
|
30
|
+
try {
|
|
31
|
+
const { stdout } = await execa('gh', [
|
|
32
|
+
'release',
|
|
33
|
+
'list',
|
|
34
|
+
'--repo',
|
|
35
|
+
FACTORY_REPO,
|
|
36
|
+
'--json',
|
|
37
|
+
'tagName,isPrerelease',
|
|
38
|
+
'--limit',
|
|
39
|
+
'30',
|
|
40
|
+
]);
|
|
41
|
+
const releases = JSON.parse(stdout);
|
|
42
|
+
const stable = releases.find((r) => !r.isPrerelease);
|
|
43
|
+
if (!stable)
|
|
44
|
+
return null;
|
|
45
|
+
return stripVersionPrefix(stable.tagName);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** Strip a leading `v` from a tag (`v9.5.0` → `9.5.0`); leave the rest as-is. */
|
|
52
|
+
function stripVersionPrefix(tag) {
|
|
53
|
+
return tag.startsWith('v') ? tag.slice(1) : tag;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Compare two semver-ish strings. Returns 1 when `a` > `b`, -1 when `a` < `b`,
|
|
57
|
+
* 0 when equal. Non-numeric / malformed segments compare as 0 so a parse
|
|
58
|
+
* surprise never flips `hasUpdate` to a false positive.
|
|
59
|
+
*/
|
|
60
|
+
function compareVersions(a, b) {
|
|
61
|
+
const pa = a.split('.').map((n) => Number.parseInt(n, 10) || 0);
|
|
62
|
+
const pb = b.split('.').map((n) => Number.parseInt(n, 10) || 0);
|
|
63
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
64
|
+
const da = pa[i] ?? 0;
|
|
65
|
+
const db = pb[i] ?? 0;
|
|
66
|
+
if (da > db)
|
|
67
|
+
return 1;
|
|
68
|
+
if (da < db)
|
|
69
|
+
return -1;
|
|
70
|
+
}
|
|
71
|
+
return 0;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Compute the status result. Pure given its inputs — the only side effect is the
|
|
75
|
+
* lockfile read (FS), and `fetchLatest` (network). Both are graceful: a missing
|
|
76
|
+
* lockfile sets `noLockfile`, a null `latest` sets `networkError`.
|
|
77
|
+
*/
|
|
78
|
+
export async function computeStatus(deps = {}) {
|
|
79
|
+
const rootDir = deps.rootDir ?? process.cwd();
|
|
80
|
+
const fetchLatest = deps.fetchLatest ?? fetchLatestRelease;
|
|
81
|
+
if (!hasLockfile(rootDir)) {
|
|
82
|
+
return {
|
|
83
|
+
installed: null,
|
|
84
|
+
factoryVersion: null,
|
|
85
|
+
latest: null,
|
|
86
|
+
hasUpdate: false,
|
|
87
|
+
noLockfile: true,
|
|
88
|
+
networkError: false,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
const lock = readLockfile(rootDir);
|
|
92
|
+
const installed = lock.version;
|
|
93
|
+
// The top-level `version` IS the agentKitVersion (the installed brain version);
|
|
94
|
+
// the top-level `factoryVersion` is the birth stamp. A legacy lockfile without
|
|
95
|
+
// a birth stamp degrades to null (it is reported as "unknown", never crashes).
|
|
96
|
+
const factoryVersion = lock.factoryVersion ?? null;
|
|
97
|
+
const latest = await fetchLatest();
|
|
98
|
+
if (latest === null) {
|
|
99
|
+
return {
|
|
100
|
+
installed,
|
|
101
|
+
factoryVersion,
|
|
102
|
+
latest: null,
|
|
103
|
+
hasUpdate: false,
|
|
104
|
+
noLockfile: false,
|
|
105
|
+
networkError: true,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
installed,
|
|
110
|
+
factoryVersion,
|
|
111
|
+
latest,
|
|
112
|
+
hasUpdate: compareVersions(latest, installed) > 0,
|
|
113
|
+
noLockfile: false,
|
|
114
|
+
networkError: false,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/** Render the status result to the console. */
|
|
118
|
+
function renderStatus(result) {
|
|
119
|
+
if (result.noLockfile) {
|
|
120
|
+
console.log('Sin lockfile detectado.');
|
|
121
|
+
console.log('Ejecuta `factory:update` para inicializar el tracking de versión.');
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
console.log(`Instalado: v${result.installed}`);
|
|
125
|
+
if (result.factoryVersion) {
|
|
126
|
+
console.log(`Sello de nacimiento (factoryVersion): v${result.factoryVersion}`);
|
|
127
|
+
}
|
|
128
|
+
if (result.networkError) {
|
|
129
|
+
console.log('Última: (versión remota no disponible — sin conexión)');
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
console.log(`Última: v${result.latest}`);
|
|
133
|
+
if (result.hasUpdate) {
|
|
134
|
+
console.log('⬆ Actualización disponible — ejecuta factory:update');
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
console.log('✔ Estás en la última versión.');
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Run `factory status`. Always exits 0 — a missing lockfile and a network error
|
|
142
|
+
* are advisory states, not failures (DIST-006 §8). Only an unexpected
|
|
143
|
+
* programming bug would surface through the top-level handler.
|
|
144
|
+
*/
|
|
145
|
+
export async function runStatus(deps = {}) {
|
|
146
|
+
const result = await computeStatus(deps);
|
|
147
|
+
renderStatus(result);
|
|
148
|
+
}
|