@timekast/factory 0.1.0 → 0.1.1

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.
@@ -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 → 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.
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('ya hay un repo aquí, usa `add`');
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. 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();
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 cwd (without the Factory's git lineage).
106
- moveContentsInto(stagedDir, cwd);
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(cwd, 'package.json');
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(cwd, manifestRaw);
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
  }
@@ -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, not silent) + a one-line summary. */
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
- console.log(`\n✔ update: ${diff.add.length} agregados, ${diff.overwriteSilent.length} actualizados, ` +
217
- `${diff.deleteSilent.length} retirados, ${diff.conflicts.length} conflictos.`);
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
@@ -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: in newManifest, absent on disk.
162
- * - overwriteSilent: in both manifests, localHash == registeredHash.
163
- * - deleteSilent: in oldLock, absent from newManifest (kit-retired).
164
- * - ignoreLocal: on disk under a tracked dir but in NO manifest → untouched.
165
- * (Populated by the caller from the disk scan; here we only
166
- * surface paths present on disk that are in neither manifest
167
- * when they were passed in `diskHashes`.)
168
- * - conflicts: localHash != registeredHash AND newHash != registeredHash.
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
- * Note on a file in newManifest, present on disk, localHash == newHash already:
171
- * it is treated as overwriteSilent (re-writing identical bytes is a no-op the
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 → silent.
201
+ // otherwise it is already correct → unchanged (no rewrite).
202
202
  if (localHash === newEntry.hash) {
203
- diff.overwriteSilent.push(newEntry);
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) silent overwrite.
219
- diff.overwriteSilent.push(newEntry);
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 → silent.
229
- diff.overwriteSilent.push(newEntry);
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: deleteSilent (in old, gone from new).
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 (!newByPath.has(oldEntry.path)) {
244
- diff.deleteSilent.push(oldEntry);
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.0",
3
+ "version": "0.1.1",
4
4
  "description": "Public, thin CLI to bootstrap and maintain TimeKast Factory derived projects.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",