@timekast/factory 0.1.0 → 0.1.4
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/new.js +33 -30
- package/dist/commands/update.js +10 -3
- package/dist/lib/lockfile.js +37 -24
- package/package.json +2 -1
package/dist/commands/new.js
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `factory new <Name>` — bootstrap a brand-new derived project (design §6.1).
|
|
3
3
|
*
|
|
4
|
-
* Flow: validate name →
|
|
5
|
-
*
|
|
6
|
-
* GitHub repo → download the profile tarball → unpack (no
|
|
7
|
-
* `git init` → rename `package.json.name` → write the initial
|
|
8
|
-
* inject
|
|
4
|
+
* Flow: validate name → refuse if inside a repo → refuse if `./<name>/` exists
|
|
5
|
+
* and is non-empty → preflight (gh) → choose profile (full | core) → create the
|
|
6
|
+
* GitHub repo → download the profile tarball → unpack into `./<name>/` (no
|
|
7
|
+
* Factory `.git/`) → `git init` → rename `package.json.name` → write the initial
|
|
8
|
+
* lockfile → inject `factory:update` → initial commit (non-fatal if no git id).
|
|
9
9
|
*
|
|
10
10
|
* Expected failures throw `CLIError`; the top-level handler prints the message
|
|
11
11
|
* and exits with the carried code.
|
|
12
12
|
*/
|
|
13
|
-
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync, } from 'node:fs';
|
|
14
14
|
import { tmpdir } from 'node:os';
|
|
15
15
|
import path from 'node:path';
|
|
16
16
|
import { execa } from 'execa';
|
|
@@ -40,18 +40,6 @@ async function promptProfile() {
|
|
|
40
40
|
}
|
|
41
41
|
return choice;
|
|
42
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
43
|
/** Create the destination GitHub repo, surfacing gh's error cleanly. */
|
|
56
44
|
async function createRepo(name) {
|
|
57
45
|
try {
|
|
@@ -69,15 +57,16 @@ export async function runNew(name) {
|
|
|
69
57
|
// 2. Refuse to run inside an existing repo (before any destructive action).
|
|
70
58
|
const cwd = process.cwd();
|
|
71
59
|
if (existsSync(path.join(cwd, '.git'))) {
|
|
72
|
-
throw new CLIError('
|
|
60
|
+
throw new CLIError('estás dentro de un repo git: sal del repo para crear un proyecto nuevo, o usa `add` para sumar el cerebro a este repo.');
|
|
73
61
|
}
|
|
74
|
-
// 3.
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
await confirmNonEmptyDir();
|
|
62
|
+
// 3. Destination = ./<name>/. Validate availability BEFORE any network call so
|
|
63
|
+
// a name clash never leaves an orphan GitHub repo (B3).
|
|
64
|
+
const destDir = path.join(cwd, validName);
|
|
65
|
+
if (existsSync(destDir) && readdirSync(destDir).length > 0) {
|
|
66
|
+
throw new CLIError(`El directorio \`${validName}\` ya existe y no está vacío.`);
|
|
80
67
|
}
|
|
68
|
+
// 4. Preflight: gh installed + authed + org member.
|
|
69
|
+
await runPreflight();
|
|
81
70
|
// 5. Choose profile.
|
|
82
71
|
const profile = await promptProfile();
|
|
83
72
|
// 6. Network: create the repo.
|
|
@@ -102,12 +91,13 @@ export async function runNew(name) {
|
|
|
102
91
|
const tarball = await downloadProfileTarball(profile, tmpDir);
|
|
103
92
|
// Extract + validate the embedded manifest before touching the destination.
|
|
104
93
|
const { stagedDir, manifestRaw } = await stageProfileTarball(tarball, tmpDir);
|
|
105
|
-
// Move contents into
|
|
106
|
-
|
|
94
|
+
// Move contents into ./<name>/ (without the Factory's git lineage).
|
|
95
|
+
mkdirSync(destDir, { recursive: true });
|
|
96
|
+
moveContentsInto(stagedDir, destDir);
|
|
107
97
|
// git init (own lineage).
|
|
108
|
-
await execa('git', ['init'], { cwd });
|
|
98
|
+
await execa('git', ['init'], { cwd: destDir });
|
|
109
99
|
// Rename package.json.name + inject factory:update (surgical, §7.4).
|
|
110
|
-
const pkgPath = path.join(
|
|
100
|
+
const pkgPath = path.join(destDir, 'package.json');
|
|
111
101
|
if (existsSync(pkgPath)) {
|
|
112
102
|
const { content, scriptAlreadyPresent } = applyPackageJsonEdits(readFileSync(pkgPath, 'utf8'), validName);
|
|
113
103
|
writeFileSync(pkgPath, content, 'utf8');
|
|
@@ -117,7 +107,19 @@ export async function runNew(name) {
|
|
|
117
107
|
}
|
|
118
108
|
// Write the initial lockfile = the tarball's embedded manifest verbatim
|
|
119
109
|
// (no hash recompute — that's DIST-005).
|
|
120
|
-
writeInitialLockfile(
|
|
110
|
+
writeInitialLockfile(destDir, manifestRaw);
|
|
111
|
+
// Initial commit (H3). Non-fatal: a fresh machine may lack git identity —
|
|
112
|
+
// the project is already usable; warn and let the dev commit by hand (B2).
|
|
113
|
+
try {
|
|
114
|
+
await execa('git', ['add', '-A'], { cwd: destDir });
|
|
115
|
+
await execa('git', ['commit', '-m', 'chore: initial commit from TimeKast Factory'], {
|
|
116
|
+
cwd: destDir,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
console.warn('Aviso: no se pudo crear el commit inicial (¿falta `git config --global user.email`/`user.name`?). ' +
|
|
121
|
+
'El proyecto está listo; commitea a mano cuando configures tu identidad git.');
|
|
122
|
+
}
|
|
121
123
|
}
|
|
122
124
|
finally {
|
|
123
125
|
process.removeListener('SIGINT', onSignal);
|
|
@@ -125,4 +127,5 @@ export async function runNew(name) {
|
|
|
125
127
|
cleanup();
|
|
126
128
|
}
|
|
127
129
|
console.log(`\n✔ Proyecto \`${validName}\` listo. Repo: ${FACTORY_ORG}/${validName}`);
|
|
130
|
+
console.log(` cd ${validName}`);
|
|
128
131
|
}
|
package/dist/commands/update.js
CHANGED
|
@@ -206,15 +206,22 @@ function warnOnScriptConflict(action, _pkgPath) {
|
|
|
206
206
|
console.warn('Aviso: `factory:update` ya existe en package.json con otro valor; no se sobrescribió.');
|
|
207
207
|
}
|
|
208
208
|
}
|
|
209
|
-
/** Print the deletes (design §7.6 — visible
|
|
209
|
+
/** Print the deletes + kept-retired (design §7.6 — visible) + a one-line summary. */
|
|
210
210
|
function reportSummary(diff) {
|
|
211
211
|
if (diff.deleteSilent.length > 0) {
|
|
212
212
|
console.log('\nArchivos retirados por el Factory en esta versión:');
|
|
213
213
|
for (const e of diff.deleteSilent)
|
|
214
214
|
console.log(` Archivos retirados: ${e.path}`);
|
|
215
215
|
}
|
|
216
|
-
|
|
217
|
-
|
|
216
|
+
if (diff.keptRetiredLocal.length > 0) {
|
|
217
|
+
console.log('\nConservados con tus cambios — ya no los gestiona el Factory:');
|
|
218
|
+
for (const e of diff.keptRetiredLocal)
|
|
219
|
+
console.log(` Conservado (editado localmente): ${e.path}`);
|
|
220
|
+
}
|
|
221
|
+
const unchanged = diff.unchanged.length > 0 ? ` (${diff.unchanged.length} sin cambios)` : '';
|
|
222
|
+
const kept = diff.keptRetiredLocal.length > 0 ? `, ${diff.keptRetiredLocal.length} conservados` : '';
|
|
223
|
+
console.log(`\n✔ update: ${diff.add.length} agregados, ${diff.overwriteSilent.length} actualizados${unchanged}, ` +
|
|
224
|
+
`${diff.deleteSilent.length} retirados${kept}, ${diff.conflicts.length} conflictos.`);
|
|
218
225
|
}
|
|
219
226
|
/**
|
|
220
227
|
* Run `factory update`. Orchestrates the full flow; `deps.acquire` is the only
|
package/dist/lib/lockfile.js
CHANGED
|
@@ -158,22 +158,20 @@ export function writeLockfile(rootDir, lockfile) {
|
|
|
158
158
|
* repo). This function only diffs the two manifests it is handed.
|
|
159
159
|
*
|
|
160
160
|
* Buckets:
|
|
161
|
-
* - add:
|
|
162
|
-
* - overwriteSilent:
|
|
163
|
-
* -
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
161
|
+
* - add: in newManifest, absent on disk.
|
|
162
|
+
* - overwriteSilent: clean on disk AND the Factory changed it → rewrite silently.
|
|
163
|
+
* - unchanged: identical on disk, lockfile AND new manifest → no-op (not
|
|
164
|
+
* rewritten, not counted as "updated"). Avoids the false
|
|
165
|
+
* "238 updated" on a fresh, drift-free derivative.
|
|
166
|
+
* - deleteSilent: in oldLock, absent from newManifest, disk clean → delete.
|
|
167
|
+
* - keptRetiredLocal: retired by the Factory but edited locally → preserve + warn
|
|
168
|
+
* (NOT a conflict — a retired file has no version in stagedDir).
|
|
169
|
+
* - ignoreLocal: on disk under a tracked dir but in NO manifest → untouched.
|
|
170
|
+
* - conflicts: localHash != registeredHash AND newHash != registeredHash
|
|
171
|
+
* AND they did not converge.
|
|
169
172
|
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
* swap performs harmlessly) UNLESS localHash != registeredHash AND
|
|
173
|
-
* newHash != registeredHash → then it is a conflict (the dev edited it and the
|
|
174
|
-
* Factory changed it, even if they happened to converge we still surface it per
|
|
175
|
-
* the design's binary rule). We special-case convergence below to avoid a
|
|
176
|
-
* pointless prompt when local already equals the incoming version.
|
|
173
|
+
* Convergence (localHash == newHash) is treated as `unchanged`: the disk already
|
|
174
|
+
* holds the incoming version, so there is nothing to rewrite or prompt about.
|
|
177
175
|
*/
|
|
178
176
|
export function diffLockfiles(oldLock, newManifest, diskHashes) {
|
|
179
177
|
const oldByPath = new Map(oldLock.files.map((f) => [f.path, f]));
|
|
@@ -181,7 +179,9 @@ export function diffLockfiles(oldLock, newManifest, diskHashes) {
|
|
|
181
179
|
const diff = {
|
|
182
180
|
add: [],
|
|
183
181
|
overwriteSilent: [],
|
|
182
|
+
unchanged: [],
|
|
184
183
|
deleteSilent: [],
|
|
184
|
+
keptRetiredLocal: [],
|
|
185
185
|
ignoreLocal: [],
|
|
186
186
|
conflicts: [],
|
|
187
187
|
};
|
|
@@ -198,9 +198,9 @@ export function diffLockfiles(oldLock, newManifest, diskHashes) {
|
|
|
198
198
|
if (registeredHash === undefined) {
|
|
199
199
|
// On disk but not in the old lockfile: untracked-but-present. Treat as a
|
|
200
200
|
// conflict only if the disk content differs from the incoming version;
|
|
201
|
-
// otherwise it is already correct →
|
|
201
|
+
// otherwise it is already correct → unchanged (no rewrite).
|
|
202
202
|
if (localHash === newEntry.hash) {
|
|
203
|
-
diff.
|
|
203
|
+
diff.unchanged.push(newEntry);
|
|
204
204
|
}
|
|
205
205
|
else {
|
|
206
206
|
diff.conflicts.push({
|
|
@@ -215,8 +215,12 @@ export function diffLockfiles(oldLock, newManifest, diskHashes) {
|
|
|
215
215
|
const localEdited = localHash !== registeredHash;
|
|
216
216
|
const factoryChanged = newEntry.hash !== registeredHash;
|
|
217
217
|
if (!localEdited) {
|
|
218
|
-
// Clean kit file (matches the lockfile)
|
|
219
|
-
|
|
218
|
+
// Clean kit file (matches the lockfile). Rewrite only if the Factory
|
|
219
|
+
// actually changed it; otherwise it is identical everywhere → no-op.
|
|
220
|
+
if (factoryChanged)
|
|
221
|
+
diff.overwriteSilent.push(newEntry);
|
|
222
|
+
else
|
|
223
|
+
diff.unchanged.push(newEntry);
|
|
220
224
|
}
|
|
221
225
|
else if (!factoryChanged) {
|
|
222
226
|
// Dev edited it, Factory did NOT change it → keep the dev's version.
|
|
@@ -225,8 +229,8 @@ export function diffLockfiles(oldLock, newManifest, diskHashes) {
|
|
|
225
229
|
diff.ignoreLocal.push(newEntry.path);
|
|
226
230
|
}
|
|
227
231
|
else if (localHash === newEntry.hash) {
|
|
228
|
-
// Converged: dev's edit happens to equal the new version →
|
|
229
|
-
diff.
|
|
232
|
+
// Converged: dev's edit happens to equal the new version → already correct.
|
|
233
|
+
diff.unchanged.push(newEntry);
|
|
230
234
|
}
|
|
231
235
|
else {
|
|
232
236
|
// Edited locally AND changed by the Factory, diverging → conflict.
|
|
@@ -238,10 +242,19 @@ export function diffLockfiles(oldLock, newManifest, diskHashes) {
|
|
|
238
242
|
});
|
|
239
243
|
}
|
|
240
244
|
}
|
|
241
|
-
// Walk the old lockfile:
|
|
245
|
+
// Walk the old lockfile: a file retired from the new manifest. Respect local
|
|
246
|
+
// edits (H7) — never silently delete a tracked file the dev changed.
|
|
242
247
|
for (const oldEntry of oldLock.files) {
|
|
243
|
-
if (
|
|
244
|
-
|
|
248
|
+
if (newByPath.has(oldEntry.path))
|
|
249
|
+
continue;
|
|
250
|
+
const localHash = diskHashes.get(oldEntry.path);
|
|
251
|
+
if (localHash === undefined)
|
|
252
|
+
continue; // dev already removed it → no-op
|
|
253
|
+
if (localHash === oldEntry.hash) {
|
|
254
|
+
diff.deleteSilent.push(oldEntry); // clean → retire
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
diff.keptRetiredLocal.push(oldEntry); // edited → preserve + warn
|
|
245
258
|
}
|
|
246
259
|
}
|
|
247
260
|
// Any disk path present in NEITHER manifest is dev-owned → ignoreLocal.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@timekast/factory",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Public, thin CLI to bootstrap and maintain TimeKast Factory derived projects.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
35
35
|
"prepublishOnly": "pnpm build",
|
|
36
36
|
"test": "vitest run",
|
|
37
|
+
"test:unit": "vitest run tests/unit",
|
|
37
38
|
"test:watch": "vitest"
|
|
38
39
|
},
|
|
39
40
|
"dependencies": {
|