@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,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lockfile engine: read / write / diff of `.timekast/lockfile.json` plus the
|
|
3
|
+
* normalized hashing the 4-bucket diff relies on (design §7.0–§7.2, §7.6).
|
|
4
|
+
*
|
|
5
|
+
* The lockfile IS the embedded `manifest.json` of the currently-installed
|
|
6
|
+
* version (design §7.0 table): a top-level `{ version, profile, files }` shape
|
|
7
|
+
* where each `files[]` entry is `{ path, hash }`. There is no per-entry version
|
|
8
|
+
* (the version lives once at the top).
|
|
9
|
+
*
|
|
10
|
+
* Two version semantics live at the top level (design §3 — dual version):
|
|
11
|
+
* - `version` = `agentKitVersion`: the installed brain version. ADVANCES
|
|
12
|
+
* on every `update` (it is whatever the new manifest carries).
|
|
13
|
+
* - `factoryVersion` = the birth stamp: the kit version at install time
|
|
14
|
+
* (`new` / `add`), FROZEN thereafter and PRESERVED across
|
|
15
|
+
* every `update`. Optional for backward-compat: a lockfile
|
|
16
|
+
* written before this field exists (or the builder's embedded
|
|
17
|
+
* manifest, which only carries `version`) simply omits it.
|
|
18
|
+
* `writeInitialLockfile` promotes the manifest's `version`
|
|
19
|
+
* into `factoryVersion` so the birth stamp is recorded.
|
|
20
|
+
*
|
|
21
|
+
* 🔒 HASH PARITY — DO NOT DIVERGE.
|
|
22
|
+
* `normalizeThenHash` MUST produce byte-for-byte the same digest as the
|
|
23
|
+
* distribution builder's `hashContent` (`distribution/build-dist.ts`). The
|
|
24
|
+
* builder normalizes with EXACTLY:
|
|
25
|
+
* raw.replace(/\r\n/g, '\n').replace(/[ \t\r\n]+$/, '')
|
|
26
|
+
* then SHA-256 (hex) over the utf8 bytes of the normalized string. Any drift
|
|
27
|
+
* here (different trim, different EOL handling, hashing raw bytes) would make
|
|
28
|
+
* every kit file in a derived repo show up as a conflict on the first `update`.
|
|
29
|
+
* If you change one side, change both and re-run the parity tests.
|
|
30
|
+
*/
|
|
31
|
+
import { createHash } from 'node:crypto';
|
|
32
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
33
|
+
import path from 'node:path';
|
|
34
|
+
import { CLIError } from './cli-error.js';
|
|
35
|
+
import { LOCKFILE_FILE, MANIFEST_FILE, TIMEKAST_DIR } from './constants.js';
|
|
36
|
+
/**
|
|
37
|
+
* Write the initial lockfile in `destDir/.timekast/lockfile.json` (used by
|
|
38
|
+
* `new` / `add`). On the initial install the lockfile carries the tarball's
|
|
39
|
+
* embedded manifest (same `version` / `profile` / `files`, no hash recompute —
|
|
40
|
+
* the diff engine is `update`) PLUS the birth stamp: `factoryVersion` is set to
|
|
41
|
+
* the manifest's `version` (install time = birth). It is frozen there and
|
|
42
|
+
* preserved by every later `update`.
|
|
43
|
+
*
|
|
44
|
+
* A stale unpacked `manifest.json` left in `.timekast/` is removed so only the
|
|
45
|
+
* stamped lockfile remains.
|
|
46
|
+
*/
|
|
47
|
+
export function writeInitialLockfile(destDir, manifestRaw) {
|
|
48
|
+
const timekastDir = path.join(destDir, TIMEKAST_DIR);
|
|
49
|
+
mkdirSync(timekastDir, { recursive: true });
|
|
50
|
+
// Parse the embedded manifest and stamp the birth seal (factoryVersion =
|
|
51
|
+
// manifest.version at install). Reuse parseLockfile so a corrupt embedded
|
|
52
|
+
// manifest is rejected here too (source label keeps the error accurate).
|
|
53
|
+
const manifest = parseLockfile(manifestRaw, 'manifest');
|
|
54
|
+
const stamped = { ...manifest, factoryVersion: manifest.version };
|
|
55
|
+
// If the tarball already unpacked manifest.json into .timekast/, drop it: the
|
|
56
|
+
// stamped lockfile is the SSOT and we do not keep a duplicate manifest around.
|
|
57
|
+
const unpackedManifest = path.join(timekastDir, MANIFEST_FILE);
|
|
58
|
+
if (existsSync(unpackedManifest)) {
|
|
59
|
+
rmSync(unpackedManifest, { force: true });
|
|
60
|
+
}
|
|
61
|
+
writeLockfile(destDir, stamped);
|
|
62
|
+
}
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Hashing — parity-locked with the builder (see header).
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
/**
|
|
67
|
+
* Normalize content the same way the builder does, then SHA-256 it (hex).
|
|
68
|
+
*
|
|
69
|
+
* Normalization: CRLF → LF, then strip trailing whitespace/newlines at the end
|
|
70
|
+
* of the file. This absorbs the common false-conflict causes (Windows CRLF,
|
|
71
|
+
* prettier's final-newline) so a clean kit file hashes identically across
|
|
72
|
+
* platforms. MUST stay byte-for-byte identical to `hashContent` in
|
|
73
|
+
* `distribution/build-dist.ts`.
|
|
74
|
+
*/
|
|
75
|
+
export function normalizeThenHash(content) {
|
|
76
|
+
const normalized = content.replace(/\r\n/g, '\n').replace(/[ \t\r\n]+$/, '');
|
|
77
|
+
return createHash('sha256').update(normalized, 'utf8').digest('hex');
|
|
78
|
+
}
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// Read / write
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
/** Absolute path to the lockfile inside a derived project root. */
|
|
83
|
+
export function lockfilePath(rootDir) {
|
|
84
|
+
return path.join(rootDir, TIMEKAST_DIR, LOCKFILE_FILE);
|
|
85
|
+
}
|
|
86
|
+
/** True when the derived project already has a lockfile. */
|
|
87
|
+
export function hasLockfile(rootDir) {
|
|
88
|
+
return existsSync(lockfilePath(rootDir));
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Validate a parsed object is a well-formed lockfile/manifest: a `version`
|
|
92
|
+
* string, a `profile`, and a `files` array of `{ path, hash }`. Used both for
|
|
93
|
+
* the lockfile on disk and the embedded manifest of a downloaded tarball
|
|
94
|
+
* (the swap preflight rejects a corrupt/empty manifest with this).
|
|
95
|
+
*
|
|
96
|
+
* @throws {CLIError} with a clear message when the structure is invalid.
|
|
97
|
+
*/
|
|
98
|
+
export function parseLockfile(raw, source = 'lockfile') {
|
|
99
|
+
let parsed;
|
|
100
|
+
try {
|
|
101
|
+
parsed = JSON.parse(raw);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
throw new CLIError(`El ${source} no es JSON válido; no se puede continuar de forma segura.`);
|
|
105
|
+
}
|
|
106
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
107
|
+
throw new CLIError(`El ${source} no tiene la estructura esperada (objeto con version + files).`);
|
|
108
|
+
}
|
|
109
|
+
const obj = parsed;
|
|
110
|
+
if (typeof obj.version !== 'string' || obj.version.length === 0) {
|
|
111
|
+
throw new CLIError(`El ${source} no declara un \`version\` válido; está corrupto o vacío.`);
|
|
112
|
+
}
|
|
113
|
+
if (obj.profile !== 'core' && obj.profile !== 'full') {
|
|
114
|
+
throw new CLIError(`El ${source} no declara un \`profile\` válido ('core' | 'full').`);
|
|
115
|
+
}
|
|
116
|
+
if (!Array.isArray(obj.files)) {
|
|
117
|
+
throw new CLIError(`El ${source} no declara un arreglo \`files\`; está corrupto o vacío.`);
|
|
118
|
+
}
|
|
119
|
+
const files = obj.files.map((entry, i) => {
|
|
120
|
+
if (typeof entry !== 'object' || entry === null) {
|
|
121
|
+
throw new CLIError(`El ${source} tiene una entrada inválida en files[${i}].`);
|
|
122
|
+
}
|
|
123
|
+
const e = entry;
|
|
124
|
+
if (typeof e.path !== 'string' || typeof e.hash !== 'string') {
|
|
125
|
+
throw new CLIError(`El ${source} tiene una entrada sin \`path\`/\`hash\` en files[${i}].`);
|
|
126
|
+
}
|
|
127
|
+
return { path: e.path, hash: e.hash };
|
|
128
|
+
});
|
|
129
|
+
return {
|
|
130
|
+
version: obj.version,
|
|
131
|
+
profile: obj.profile,
|
|
132
|
+
files,
|
|
133
|
+
// Optional birth stamp — present in stamped lockfiles, absent in the
|
|
134
|
+
// builder's embedded manifest and in legacy lockfiles (back-compat).
|
|
135
|
+
...(typeof obj.factoryVersion === 'string' ? { factoryVersion: obj.factoryVersion } : {}),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
/** Read + validate the lockfile from a derived project root. */
|
|
139
|
+
export function readLockfile(rootDir) {
|
|
140
|
+
const file = lockfilePath(rootDir);
|
|
141
|
+
if (!existsSync(file)) {
|
|
142
|
+
throw new CLIError('No hay `.timekast/lockfile.json` en este repo.');
|
|
143
|
+
}
|
|
144
|
+
return parseLockfile(readFileSync(file, 'utf8'), 'lockfile');
|
|
145
|
+
}
|
|
146
|
+
/** Write the lockfile (pretty-printed, trailing newline) to a project root. */
|
|
147
|
+
export function writeLockfile(rootDir, lockfile) {
|
|
148
|
+
const dir = path.join(rootDir, TIMEKAST_DIR);
|
|
149
|
+
mkdirSync(dir, { recursive: true });
|
|
150
|
+
writeFileSync(lockfilePath(rootDir), `${JSON.stringify(lockfile, null, 2)}\n`, 'utf8');
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Classify every file into the 4 buckets + conflicts (design §7.2).
|
|
154
|
+
*
|
|
155
|
+
* Profile stickiness is the caller's job: `newManifest` is already the manifest
|
|
156
|
+
* of the SAME profile recorded in `oldLock` (an `sk-*` only present in `full`
|
|
157
|
+
* never appears in a `core` manifest, so it can never reach `add` for a `core`
|
|
158
|
+
* repo). This function only diffs the two manifests it is handed.
|
|
159
|
+
*
|
|
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.
|
|
169
|
+
*
|
|
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.
|
|
177
|
+
*/
|
|
178
|
+
export function diffLockfiles(oldLock, newManifest, diskHashes) {
|
|
179
|
+
const oldByPath = new Map(oldLock.files.map((f) => [f.path, f]));
|
|
180
|
+
const newByPath = new Map(newManifest.files.map((f) => [f.path, f]));
|
|
181
|
+
const diff = {
|
|
182
|
+
add: [],
|
|
183
|
+
overwriteSilent: [],
|
|
184
|
+
deleteSilent: [],
|
|
185
|
+
ignoreLocal: [],
|
|
186
|
+
conflicts: [],
|
|
187
|
+
};
|
|
188
|
+
// Walk the new manifest: add / overwriteSilent / conflicts.
|
|
189
|
+
for (const newEntry of newManifest.files) {
|
|
190
|
+
const localHash = diskHashes.get(newEntry.path);
|
|
191
|
+
const registered = oldByPath.get(newEntry.path);
|
|
192
|
+
if (localHash === undefined) {
|
|
193
|
+
// Not on disk → brand-new kit file.
|
|
194
|
+
diff.add.push(newEntry);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const registeredHash = registered?.hash;
|
|
198
|
+
if (registeredHash === undefined) {
|
|
199
|
+
// On disk but not in the old lockfile: untracked-but-present. Treat as a
|
|
200
|
+
// conflict only if the disk content differs from the incoming version;
|
|
201
|
+
// otherwise it is already correct → silent.
|
|
202
|
+
if (localHash === newEntry.hash) {
|
|
203
|
+
diff.overwriteSilent.push(newEntry);
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
diff.conflicts.push({
|
|
207
|
+
path: newEntry.path,
|
|
208
|
+
localHash,
|
|
209
|
+
registeredHash: localHash, // no prior record; treat current as baseline
|
|
210
|
+
newHash: newEntry.hash,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
const localEdited = localHash !== registeredHash;
|
|
216
|
+
const factoryChanged = newEntry.hash !== registeredHash;
|
|
217
|
+
if (!localEdited) {
|
|
218
|
+
// Clean kit file (matches the lockfile) → silent overwrite.
|
|
219
|
+
diff.overwriteSilent.push(newEntry);
|
|
220
|
+
}
|
|
221
|
+
else if (!factoryChanged) {
|
|
222
|
+
// Dev edited it, Factory did NOT change it → keep the dev's version.
|
|
223
|
+
// Not a conflict and not an overwrite: leave it (no bucket = untouched).
|
|
224
|
+
// Recorded as ignoreLocal so the summary can stay accurate.
|
|
225
|
+
diff.ignoreLocal.push(newEntry.path);
|
|
226
|
+
}
|
|
227
|
+
else if (localHash === newEntry.hash) {
|
|
228
|
+
// Converged: dev's edit happens to equal the new version → silent.
|
|
229
|
+
diff.overwriteSilent.push(newEntry);
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
// Edited locally AND changed by the Factory, diverging → conflict.
|
|
233
|
+
diff.conflicts.push({
|
|
234
|
+
path: newEntry.path,
|
|
235
|
+
localHash,
|
|
236
|
+
registeredHash,
|
|
237
|
+
newHash: newEntry.hash,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
// Walk the old lockfile: deleteSilent (in old, gone from new).
|
|
242
|
+
for (const oldEntry of oldLock.files) {
|
|
243
|
+
if (!newByPath.has(oldEntry.path)) {
|
|
244
|
+
diff.deleteSilent.push(oldEntry);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
// Any disk path present in NEITHER manifest is dev-owned → ignoreLocal.
|
|
248
|
+
for (const diskPath of diskHashes.keys()) {
|
|
249
|
+
if (!newByPath.has(diskPath) && !oldByPath.has(diskPath)) {
|
|
250
|
+
diff.ignoreLocal.push(diskPath);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return diff;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Classify a legacy repo (no lockfile) against the latest manifest by PATH, not
|
|
257
|
+
* hash (design §6.3 "Baseline legacy por path-match, NO por hash"). Files that
|
|
258
|
+
* evolved between birth and now would never match by hash even though they are
|
|
259
|
+
* 100% kit-owned, so path-match is the only safe baseline.
|
|
260
|
+
*
|
|
261
|
+
* @param manifest The fresh manifest (the profile being installed).
|
|
262
|
+
* @param repoPaths The set of paths currently present under the kit-managed
|
|
263
|
+
* tree (`.claude/` + tracked root files) in the repo.
|
|
264
|
+
*/
|
|
265
|
+
export function planAutoRegister(manifest, repoPaths) {
|
|
266
|
+
const manifestPaths = new Set(manifest.files.map((f) => f.path));
|
|
267
|
+
const plan = { kitOwned: [], toAdd: [], ambiguous: [] };
|
|
268
|
+
for (const entry of manifest.files) {
|
|
269
|
+
if (repoPaths.has(entry.path)) {
|
|
270
|
+
plan.kitOwned.push(entry);
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
plan.toAdd.push(entry);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
for (const repoPath of repoPaths) {
|
|
277
|
+
if (!manifestPaths.has(repoPath)) {
|
|
278
|
+
plan.ambiguous.push(repoPath);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return plan;
|
|
282
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Surgical edits to a derived project's `package.json` (design §7.4).
|
|
3
|
+
*
|
|
4
|
+
* Both operations parse → mutate a single key → re-serialize with the original
|
|
5
|
+
* indentation, never rewriting the whole document. The dev's deps, scripts,
|
|
6
|
+
* field order and formatting are preserved.
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { CLIError } from './cli-error.js';
|
|
10
|
+
import { UPDATE_SCRIPT_CMD, UPDATE_SCRIPT_NAME } from './constants.js';
|
|
11
|
+
/** Detect the indentation used by an existing JSON document (defaults to 2 spaces). */
|
|
12
|
+
function detectIndent(raw) {
|
|
13
|
+
const match = raw.match(/^(\s+)"/m);
|
|
14
|
+
if (!match)
|
|
15
|
+
return 2;
|
|
16
|
+
const ws = match[1].replace(/\r?\n/g, '');
|
|
17
|
+
if (ws.includes('\t'))
|
|
18
|
+
return '\t';
|
|
19
|
+
return ws.length || 2;
|
|
20
|
+
}
|
|
21
|
+
/** Parse `package.json` content, raising a clean error if it is not valid JSON. */
|
|
22
|
+
export function parsePackageJson(raw) {
|
|
23
|
+
try {
|
|
24
|
+
const parsed = JSON.parse(raw);
|
|
25
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
26
|
+
throw new Error('not an object');
|
|
27
|
+
}
|
|
28
|
+
return parsed;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
throw new CLIError('El `package.json` desempacado no es JSON válido; no se puede continuar de forma segura.');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Pure core of the script insertion: mutate `pkg.scripts` in place to ensure
|
|
36
|
+
* `factory:update` exists, never overwriting a divergent value. Shared by
|
|
37
|
+
* `applyPackageJsonEdits` (the `new` flow) and `insertFactoryUpdateScript`
|
|
38
|
+
* (the `update` flow) so both follow the same §7.4 rule.
|
|
39
|
+
*/
|
|
40
|
+
function ensureUpdateScript(pkg) {
|
|
41
|
+
const scripts = pkg.scripts ?? {};
|
|
42
|
+
const existing = scripts[UPDATE_SCRIPT_NAME];
|
|
43
|
+
pkg.scripts = scripts;
|
|
44
|
+
if (existing === undefined) {
|
|
45
|
+
scripts[UPDATE_SCRIPT_NAME] = UPDATE_SCRIPT_CMD;
|
|
46
|
+
return { action: 'added' };
|
|
47
|
+
}
|
|
48
|
+
if (existing === UPDATE_SCRIPT_CMD) {
|
|
49
|
+
return { action: 'already-correct' };
|
|
50
|
+
}
|
|
51
|
+
return { action: 'conflict', existingValue: existing };
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Rename `package.json.name` and ensure the `factory:update` script exists,
|
|
55
|
+
* preserving everything else. Returns the serialized document (with the
|
|
56
|
+
* original indentation + trailing newline).
|
|
57
|
+
*
|
|
58
|
+
* If `factory:update` already exists with a different value it is left intact;
|
|
59
|
+
* `scriptAlreadyPresent` reports that so the caller can warn the user.
|
|
60
|
+
*/
|
|
61
|
+
export function applyPackageJsonEdits(raw, name) {
|
|
62
|
+
const indent = detectIndent(raw);
|
|
63
|
+
const pkg = parsePackageJson(raw);
|
|
64
|
+
pkg.name = name;
|
|
65
|
+
const result = ensureUpdateScript(pkg);
|
|
66
|
+
const content = `${JSON.stringify(pkg, null, indent)}\n`;
|
|
67
|
+
return { content, scriptAlreadyPresent: result.action === 'conflict' };
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Surgically ensure the `factory:update` script exists in the `package.json` at
|
|
71
|
+
* `pkgPath` (design §7.4). Reads, mutates only the single key, and re-serializes
|
|
72
|
+
* with the original indentation + trailing newline. NEVER overwrites a divergent
|
|
73
|
+
* value and NEVER touches `name`, deps, or any other script.
|
|
74
|
+
*
|
|
75
|
+
* Writes the file only when something actually changed (`added`); for
|
|
76
|
+
* `already-correct` and `conflict` the file is left byte-identical.
|
|
77
|
+
*
|
|
78
|
+
* @param pkgPath Absolute path to the derived project's `package.json`.
|
|
79
|
+
* @returns What happened, plus the existing value when it conflicts.
|
|
80
|
+
*/
|
|
81
|
+
export function insertFactoryUpdateScript(pkgPath) {
|
|
82
|
+
const raw = readFileSync(pkgPath, 'utf8');
|
|
83
|
+
const indent = detectIndent(raw);
|
|
84
|
+
const pkg = parsePackageJson(raw);
|
|
85
|
+
const result = ensureUpdateScript(pkg);
|
|
86
|
+
if (result.action === 'added') {
|
|
87
|
+
writeFileSync(pkgPath, `${JSON.stringify(pkg, null, indent)}\n`, 'utf8');
|
|
88
|
+
}
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preflight checks shared by `new` / `add` / `update` (design §7.7).
|
|
3
|
+
*
|
|
4
|
+
* Verifies, in order:
|
|
5
|
+
* 1. `gh` is installed and on PATH.
|
|
6
|
+
* 2. `gh auth status` exits 0 (a valid, non-expired session).
|
|
7
|
+
* 3. The authenticated user is a member of the destination org.
|
|
8
|
+
*
|
|
9
|
+
* Any failure throws a `PreflightError` with an actionable message — never a
|
|
10
|
+
* cryptic spawn error or a stack trace.
|
|
11
|
+
*/
|
|
12
|
+
import { execa } from 'execa';
|
|
13
|
+
import { PreflightError } from './cli-error.js';
|
|
14
|
+
import { FACTORY_ORG } from './constants.js';
|
|
15
|
+
/** True when the thrown error is execa's "command not found" (ENOENT). */
|
|
16
|
+
function isCommandNotFound(error) {
|
|
17
|
+
const code = error?.code;
|
|
18
|
+
return code === 'ENOENT';
|
|
19
|
+
}
|
|
20
|
+
/** Step 1 — `gh` is installed on PATH. */
|
|
21
|
+
async function assertGhInstalled() {
|
|
22
|
+
try {
|
|
23
|
+
await execa('gh', ['--version']);
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (isCommandNotFound(error)) {
|
|
27
|
+
throw new PreflightError('GitHub CLI (`gh`) no está instalado o no está en el PATH.\n' +
|
|
28
|
+
'Instálalo desde https://cli.github.com y vuelve a intentar.');
|
|
29
|
+
}
|
|
30
|
+
throw new PreflightError('No se pudo ejecutar `gh`. Verifica tu instalación de GitHub CLI (https://cli.github.com).');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Step 2 — `gh auth status` returns exit 0. */
|
|
34
|
+
async function assertGhAuthenticated() {
|
|
35
|
+
try {
|
|
36
|
+
await execa('gh', ['auth', 'status']);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
throw new PreflightError('Tu sesión de GitHub CLI no es válida o expiró.\n' +
|
|
40
|
+
'Ejecuta `gh auth login` para autenticarte y vuelve a intentar.');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Step 3 — the authenticated user is a member of `FACTORY_ORG`.
|
|
45
|
+
*
|
|
46
|
+
* `gh api /user` resolves the login; `gh api /orgs/<org>/members/<login>`
|
|
47
|
+
* returns 204 for a member and 404 otherwise (the same 404 a non-member sees
|
|
48
|
+
* on the private repo). Either non-204 → not a verifiable member.
|
|
49
|
+
*/
|
|
50
|
+
async function assertOrgMembership() {
|
|
51
|
+
let login;
|
|
52
|
+
try {
|
|
53
|
+
const { stdout } = await execa('gh', ['api', '/user', '--jq', '.login']);
|
|
54
|
+
login = stdout.trim();
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
throw new PreflightError('No se pudo determinar tu usuario de GitHub con `gh api /user`.\n' +
|
|
58
|
+
'Verifica tu sesión con `gh auth status`.');
|
|
59
|
+
}
|
|
60
|
+
if (!login) {
|
|
61
|
+
throw new PreflightError('No se pudo determinar tu usuario de GitHub. Verifica tu sesión con `gh auth status`.');
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
await execa('gh', ['api', `/orgs/${FACTORY_ORG}/members/${login}`]);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
throw new PreflightError(`No se pudo verificar que \`${login}\` sea miembro del org \`${FACTORY_ORG}\`.\n` +
|
|
68
|
+
`Solicita acceso al org \`${FACTORY_ORG}\` o verifica tu sesión con \`gh auth status\`.`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Run all preflight checks. Reusable by `add` / `update`.
|
|
73
|
+
* @throws {PreflightError} when any check fails.
|
|
74
|
+
*/
|
|
75
|
+
export async function runPreflight() {
|
|
76
|
+
await assertGhInstalled();
|
|
77
|
+
await assertGhAuthenticated();
|
|
78
|
+
await assertOrgMembership();
|
|
79
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git repo detection for `add` (design §6.2, edge cases in DIST-004 §8).
|
|
3
|
+
*
|
|
4
|
+
* Walks up from `cwd` looking for a `.git/` entry (the same lineage discovery
|
|
5
|
+
* `git rev-parse --show-toplevel` does). A `.git` may be a directory (normal
|
|
6
|
+
* repo) or a file (worktree / submodule pointer) — both count as a repo context.
|
|
7
|
+
*
|
|
8
|
+
* Note: presence of `.git/` is enough; a repo with `git init` but zero commits
|
|
9
|
+
* is a valid context (DIST-004 §8). `add` installs in the CWD, not the repo
|
|
10
|
+
* root — `repoRoot` is informational (e.g. monorepo with `.git/` in an ancestor).
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync } from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
/**
|
|
15
|
+
* Detect whether `cwd` (or any ancestor) is inside a git repository.
|
|
16
|
+
*
|
|
17
|
+
* @param cwd Absolute or relative directory to start the upward search from.
|
|
18
|
+
*/
|
|
19
|
+
export function detectRepo(cwd) {
|
|
20
|
+
let current = path.resolve(cwd);
|
|
21
|
+
// Walk up until the filesystem root; `path.dirname('/') === '/'`.
|
|
22
|
+
for (;;) {
|
|
23
|
+
if (existsSync(path.join(current, '.git'))) {
|
|
24
|
+
return { hasRepo: true, repoRoot: current };
|
|
25
|
+
}
|
|
26
|
+
const parent = path.dirname(current);
|
|
27
|
+
if (parent === current)
|
|
28
|
+
break;
|
|
29
|
+
current = parent;
|
|
30
|
+
}
|
|
31
|
+
return { hasRepo: false, repoRoot: null };
|
|
32
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared download + unpack engine for `new` and `add` (design §7.0, §7.5).
|
|
3
|
+
*
|
|
4
|
+
* A profile tarball is self-describing: it ships a `.timekast/manifest.json`
|
|
5
|
+
* (list of files + hashes for that version) at its root. The engine:
|
|
6
|
+
* 1. Downloads the tarball for a given profile from the latest Factory release.
|
|
7
|
+
* 2. Extracts it into a staging dir under a caller-provided temp dir.
|
|
8
|
+
* 3. Validates the embedded manifest exists (atomicity guard — never touch the
|
|
9
|
+
* destination until the tarball is proven well-formed).
|
|
10
|
+
*
|
|
11
|
+
* Moving the staged contents into the destination is `moveContentsInto`, kept
|
|
12
|
+
* separate so callers control the final placement step (the atomic swap of §7.5).
|
|
13
|
+
*
|
|
14
|
+
* Expected failures throw `CLIError`; the top-level handler prints + exits.
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync, readdirSync, readFileSync, cpSync, mkdirSync } from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { execa } from 'execa';
|
|
19
|
+
import { extract } from 'tar';
|
|
20
|
+
import { CLIError } from './cli-error.js';
|
|
21
|
+
import { FACTORY_REPO, MANIFEST_FILE, TIMEKAST_DIR } from './constants.js';
|
|
22
|
+
/** Download the profile tarball into `tmpDir`; returns the tarball path. */
|
|
23
|
+
export async function downloadProfileTarball(profile, tmpDir) {
|
|
24
|
+
try {
|
|
25
|
+
await execa('gh', [
|
|
26
|
+
'release',
|
|
27
|
+
'download',
|
|
28
|
+
'--repo',
|
|
29
|
+
FACTORY_REPO,
|
|
30
|
+
'--pattern',
|
|
31
|
+
`tk-${profile}-*.tgz`,
|
|
32
|
+
'--dir',
|
|
33
|
+
tmpDir,
|
|
34
|
+
]);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
throw new CLIError(`No se encontró un release publicado con el perfil \`${profile}\` en \`${FACTORY_REPO}\`.\n` +
|
|
38
|
+
'Verifica que exista un release con los assets de distribución.');
|
|
39
|
+
}
|
|
40
|
+
const downloaded = readdirSync(tmpDir).filter((f) => f.endsWith('.tgz'));
|
|
41
|
+
if (downloaded.length === 0) {
|
|
42
|
+
throw new CLIError(`El release no contiene un asset para el perfil \`${profile}\`. ` +
|
|
43
|
+
'Verifica el release del Factory.');
|
|
44
|
+
}
|
|
45
|
+
return path.join(tmpDir, downloaded[0]);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Extract `tarball` into a fresh staging dir under `tmpDir` and validate that it
|
|
49
|
+
* carries an embedded `.timekast/manifest.json`. Returns the staging dir + the
|
|
50
|
+
* manifest text. Never touches the destination — that is the caller's job.
|
|
51
|
+
*
|
|
52
|
+
* @throws {CLIError} if the manifest is missing or empty (corrupt/incomplete tarball).
|
|
53
|
+
*/
|
|
54
|
+
export async function stageProfileTarball(tarball, tmpDir) {
|
|
55
|
+
const stagedDir = path.join(tmpDir, 'unpacked');
|
|
56
|
+
mkdirSync(stagedDir, { recursive: true });
|
|
57
|
+
await extract({ file: tarball, cwd: stagedDir });
|
|
58
|
+
const manifestPath = path.join(stagedDir, TIMEKAST_DIR, MANIFEST_FILE);
|
|
59
|
+
if (!existsSync(manifestPath)) {
|
|
60
|
+
throw new CLIError('El tarball descargado no contiene `.timekast/manifest.json`; no se puede continuar.');
|
|
61
|
+
}
|
|
62
|
+
const manifestRaw = readFileSync(manifestPath, 'utf8');
|
|
63
|
+
if (manifestRaw.trim().length === 0) {
|
|
64
|
+
throw new CLIError('El manifest del tarball descargado está vacío; no se puede continuar. Reintenta `add`.');
|
|
65
|
+
}
|
|
66
|
+
return { stagedDir, manifestRaw };
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Move the staged tarball contents into `destDir`. The tarball is packed from a
|
|
70
|
+
* staging dir, so its contents sit at the tarball root — never a Factory `.git/`
|
|
71
|
+
* (Filtro 2, design §4.3). Defensive: skip any `.git` that might slip in.
|
|
72
|
+
*
|
|
73
|
+
* Profile scoping is structural: a `core` tarball only contains `core` files, so
|
|
74
|
+
* moving its contents in never reaches `src/` or dev-owned paths. Files in the
|
|
75
|
+
* destination that are NOT in the tarball are left untouched (cpSync only writes
|
|
76
|
+
* the entries it iterates).
|
|
77
|
+
*/
|
|
78
|
+
export function moveContentsInto(stagedDir, destDir) {
|
|
79
|
+
for (const entry of readdirSync(stagedDir)) {
|
|
80
|
+
if (entry === '.git')
|
|
81
|
+
continue;
|
|
82
|
+
const src = path.join(stagedDir, entry);
|
|
83
|
+
const dst = path.join(destDir, entry);
|
|
84
|
+
cpSync(src, dst, { recursive: true });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project-name validation for `new`. Runs entirely offline, BEFORE any `gh`
|
|
3
|
+
* call, so an invalid name fails fast with a format hint instead of a cryptic
|
|
4
|
+
* GitHub API error.
|
|
5
|
+
*
|
|
6
|
+
* GitHub repo names allow alphanumerics, hyphen, underscore and period; they
|
|
7
|
+
* cannot be empty, cannot be `.`/`..`, and we additionally reject names that
|
|
8
|
+
* start with `-`/`.` (ambiguous with flags / hidden paths) per design §8.
|
|
9
|
+
*/
|
|
10
|
+
import { CLIError } from './cli-error.js';
|
|
11
|
+
/** Allowed characters for a GitHub repository name. */
|
|
12
|
+
const VALID_NAME = /^[A-Za-z0-9._-]+$/;
|
|
13
|
+
const FORMAT_HINT = 'El nombre solo puede contener letras, números, guiones (-), guiones bajos (_) y puntos (.), ' +
|
|
14
|
+
'sin espacios, y no puede empezar con `-` ni `.`.';
|
|
15
|
+
/**
|
|
16
|
+
* Validate a project name. Returns the trimmed name on success.
|
|
17
|
+
* @throws {CLIError} with the allowed-format hint on any invalid input.
|
|
18
|
+
*/
|
|
19
|
+
export function validateProjectName(raw) {
|
|
20
|
+
const name = (raw ?? '').trim();
|
|
21
|
+
if (!name) {
|
|
22
|
+
throw new CLIError(`Debes indicar un nombre de proyecto.\n${FORMAT_HINT}`);
|
|
23
|
+
}
|
|
24
|
+
if (name === '.' || name === '..') {
|
|
25
|
+
throw new CLIError(`Nombre inválido: \`${name}\`.\n${FORMAT_HINT}`);
|
|
26
|
+
}
|
|
27
|
+
if (name.startsWith('-') || name.startsWith('.')) {
|
|
28
|
+
throw new CLIError(`Nombre inválido: \`${name}\` no puede empezar con \`-\` ni \`.\`.\n${FORMAT_HINT}`);
|
|
29
|
+
}
|
|
30
|
+
if (!VALID_NAME.test(name)) {
|
|
31
|
+
throw new CLIError(`Nombre inválido: \`${name}\` contiene caracteres no permitidos.\n${FORMAT_HINT}`);
|
|
32
|
+
}
|
|
33
|
+
return name;
|
|
34
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@timekast/factory",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Public, thin CLI to bootstrap and maintain TimeKast Factory derived projects.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "UNLICENSED",
|
|
7
|
+
"author": "TimeKast",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/TimeKast/TimeKast-Factory.git",
|
|
11
|
+
"directory": "cli"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"timekast",
|
|
15
|
+
"factory",
|
|
16
|
+
"cli",
|
|
17
|
+
"scaffold",
|
|
18
|
+
"boilerplate"
|
|
19
|
+
],
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"bin": {
|
|
24
|
+
"factory": "./dist/index.js"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist"
|
|
28
|
+
],
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=22"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "tsc -p tsconfig.json",
|
|
34
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
35
|
+
"prepublishOnly": "pnpm build",
|
|
36
|
+
"test": "vitest run",
|
|
37
|
+
"test:watch": "vitest"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"execa": "^9.5.2",
|
|
41
|
+
"prompts": "^2.4.2",
|
|
42
|
+
"tar": "^7.4.3"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/node": "^22.10.5",
|
|
46
|
+
"@types/prompts": "^2.4.9",
|
|
47
|
+
"tsx": "^4.19.2",
|
|
48
|
+
"typescript": "^5.7.3",
|
|
49
|
+
"vitest": "^3.0.4"
|
|
50
|
+
}
|
|
51
|
+
}
|