@timekast/factory 1.0.0 → 1.2.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/doctor.js +2 -24
- package/dist/commands/publish.js +181 -0
- package/dist/commands/unpublish.js +81 -0
- package/dist/commands/update.js +2 -25
- package/dist/index.js +12 -0
- package/dist/lib/claude-paths.js +68 -0
- package/dist/lib/constants.js +26 -1
- package/dist/lib/package-json.js +41 -26
- package/dist/lib/publish-core.js +122 -0
- package/dist/lib/publish-engine.js +133 -0
- package/package.json +1 -1
package/dist/commands/doctor.js
CHANGED
|
@@ -16,8 +16,9 @@
|
|
|
16
16
|
* No lockfile (pre-CLI repo) → delegate to the "run factory:update" advice, exit
|
|
17
17
|
* 0, never abort (same pattern as `status` / the §8 edge case).
|
|
18
18
|
*/
|
|
19
|
-
import { existsSync, readFileSync,
|
|
19
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
20
20
|
import path from 'node:path';
|
|
21
|
+
import { collectClaudePaths } from '../lib/claude-paths.js';
|
|
21
22
|
import { CLAUDE_MD_FILE } from '../lib/constants.js';
|
|
22
23
|
import { hasLockfile, normalizeThenHash, readLockfile } from '../lib/lockfile.js';
|
|
23
24
|
import { detectRepo } from '../lib/repo-detection.js';
|
|
@@ -42,29 +43,6 @@ export const A1_WARNING = 'Aviso de seguridad (A1): el boilerplate `src/` y sus
|
|
|
42
43
|
'dependencia) NO llega por este canal. El canal de parches de `src/` es EPIC 2 ' +
|
|
43
44
|
'(update agentico con merge inteligente), aún no disponible. Mantén `src/` y tus ' +
|
|
44
45
|
'dependencias al día por tu cuenta mientras tanto.';
|
|
45
|
-
/**
|
|
46
|
-
* Recursively collect repo-relative POSIX paths of files under `.claude/`. Only
|
|
47
|
-
* descends into `.claude/`; `src/` and everything else is invisible (design
|
|
48
|
-
* §7.2). Mirrors the `update` engine's `collectClaudePaths`.
|
|
49
|
-
*/
|
|
50
|
-
function collectClaudePaths(rootDir) {
|
|
51
|
-
const out = [];
|
|
52
|
-
const walk = (rel) => {
|
|
53
|
-
const abs = path.join(rootDir, rel);
|
|
54
|
-
if (!existsSync(abs))
|
|
55
|
-
return;
|
|
56
|
-
if (statSync(abs).isDirectory()) {
|
|
57
|
-
for (const entry of readdirSync(abs)) {
|
|
58
|
-
walk(path.posix.join(rel, entry));
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
else {
|
|
62
|
-
out.push(rel);
|
|
63
|
-
}
|
|
64
|
-
};
|
|
65
|
-
walk('.claude');
|
|
66
|
-
return out;
|
|
67
|
-
}
|
|
68
46
|
/**
|
|
69
47
|
* Concatenate the body of the reserved "on-demand reference" section of CLAUDE.md
|
|
70
48
|
* — the lines under a heading matching `ON_DEMAND_HEADING_RE`, until the next
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `factory publish <proposal|mockup|both>` — publish a derived project's static
|
|
3
|
+
* deliverable(s) to the `TimeKast/proposals` hub (plan v4.1).
|
|
4
|
+
*
|
|
5
|
+
* Security model is honest: link-no-adivinable + noindex only (no password yet).
|
|
6
|
+
* Identity = repo name (unique in the org) + unguessable token; both sticky in
|
|
7
|
+
* `project/.publish.json`. The CLI pushes to the hub but NEVER commits the derived
|
|
8
|
+
* repo — that's the workflow's job (the `--commit` opt-in is for standalone use).
|
|
9
|
+
*
|
|
10
|
+
* Expected failures throw `CLIError`; the top-level handler prints + exits.
|
|
11
|
+
*/
|
|
12
|
+
import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { execa } from 'execa';
|
|
16
|
+
import { CLIError } from '../lib/cli-error.js';
|
|
17
|
+
import { PROPOSALS_HUB_REPO } from '../lib/constants.js';
|
|
18
|
+
import { buildUrl, DELIVERABLE_DIR, deliverableExists, findExistingToken, findHtmlEntry, generateToken, hasNoindexMeta, parseRepoSlug, proposalFolder, readPublishState, slugify, writePublishState, } from '../lib/publish-core.js';
|
|
19
|
+
import { cloneHubShallow, commitAndPush, copyDeliverable, gitRemoteUrl, listHubFolders, resolveAuthToken, } from '../lib/publish-engine.js';
|
|
20
|
+
import { detectRepo } from '../lib/repo-detection.js';
|
|
21
|
+
/** Parse `publish`'s argv: a mandatory `<proposal|mockup|both>` + flags. */
|
|
22
|
+
export function parsePublishArgs(argv) {
|
|
23
|
+
const positional = argv.find((a) => !a.startsWith('-'));
|
|
24
|
+
if (!positional) {
|
|
25
|
+
throw new CLIError('Falta el argumento. Uso: `factory publish <proposal|mockup|both>` ' +
|
|
26
|
+
'(explícito a propósito — no se publica "lo que haya").');
|
|
27
|
+
}
|
|
28
|
+
let targets;
|
|
29
|
+
if (positional === 'both')
|
|
30
|
+
targets = ['proposal', 'mockup'];
|
|
31
|
+
else if (positional === 'proposal' || positional === 'mockup')
|
|
32
|
+
targets = [positional];
|
|
33
|
+
else {
|
|
34
|
+
throw new CLIError(`Argumento inválido: \`${positional}\`. Usa \`proposal\`, \`mockup\` o \`both\`.`);
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
targets,
|
|
38
|
+
flags: {
|
|
39
|
+
opaqueUrl: argv.includes('--opaque-url'),
|
|
40
|
+
changeUrl: argv.includes('--change-url'),
|
|
41
|
+
commit: argv.includes('--commit'),
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export async function runPublish(parsed) {
|
|
46
|
+
const cwd = process.cwd();
|
|
47
|
+
const { hasRepo, repoRoot } = detectRepo(cwd);
|
|
48
|
+
if (!hasRepo) {
|
|
49
|
+
throw new CLIError('No hay repositorio git aquí. `factory publish` corre dentro de un derivado.');
|
|
50
|
+
}
|
|
51
|
+
const root = repoRoot ?? cwd;
|
|
52
|
+
// Only publish requested deliverables that actually exist.
|
|
53
|
+
const present = parsed.targets.filter((t) => deliverableExists(root, t));
|
|
54
|
+
if (present.length === 0) {
|
|
55
|
+
throw new CLIError(`No encontré ${parsed.targets.join('/')} en este repo. ` +
|
|
56
|
+
'Corre `/proposal` o `/mockup` primero para generar el entregable.');
|
|
57
|
+
}
|
|
58
|
+
// Auth (dual): env token (headless) or gh + org membership (dev local).
|
|
59
|
+
const token = await resolveAuthToken();
|
|
60
|
+
// Identity: repo name from the git remote (unique in the org). Fallback to dir.
|
|
61
|
+
const remoteUrl = await gitRemoteUrl(root);
|
|
62
|
+
const repo = (remoteUrl && parseRepoSlug(remoteUrl)) || slugify(path.basename(root));
|
|
63
|
+
if (!remoteUrl) {
|
|
64
|
+
console.warn(`Aviso: este repo no tiene remote \`origin\`; uso el nombre del directorio (\`${repo}\`) como identidad.`);
|
|
65
|
+
}
|
|
66
|
+
const existing = readPublishState(root);
|
|
67
|
+
// Shallow-clone the hub into an ephemeral tmpdir.
|
|
68
|
+
const tmp = mkdtempSync(path.join(tmpdir(), 'tk-publish-'));
|
|
69
|
+
const cleanup = () => {
|
|
70
|
+
try {
|
|
71
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
/* best effort */
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
const onSignal = () => {
|
|
78
|
+
cleanup();
|
|
79
|
+
process.exit(130);
|
|
80
|
+
};
|
|
81
|
+
process.once('SIGINT', onSignal);
|
|
82
|
+
process.once('SIGTERM', onSignal);
|
|
83
|
+
try {
|
|
84
|
+
const hubDir = path.join(tmp, 'hub');
|
|
85
|
+
await cloneHubShallow(token, hubDir);
|
|
86
|
+
// Resolve token + urlShape (sticky in .publish.json; hub-query as fallback).
|
|
87
|
+
const { proposalToken, urlShape } = resolveTokenAndShape(existing, parsed.flags, repo, hubDir);
|
|
88
|
+
const folder = proposalFolder(repo, proposalToken, urlShape);
|
|
89
|
+
const urls = { ...(existing?.urls ?? {}) };
|
|
90
|
+
const publishedFolders = new Set();
|
|
91
|
+
for (const type of present) {
|
|
92
|
+
const srcDir = path.join(root, DELIVERABLE_DIR[type]);
|
|
93
|
+
const files = readdirSync(srcDir);
|
|
94
|
+
const htmlEntry = findHtmlEntry(files);
|
|
95
|
+
if (!htmlEntry) {
|
|
96
|
+
throw new CLIError(`No encontré un HTML en \`${DELIVERABLE_DIR[type]}\`. ¿Se generó el entregable?`);
|
|
97
|
+
}
|
|
98
|
+
const indexPath = copyDeliverable(srcDir, hubDir, folder, type, htmlEntry);
|
|
99
|
+
// Verify (not inject) the noindex meta — the template owns the HTML.
|
|
100
|
+
const html = readFileSync(indexPath, 'utf8');
|
|
101
|
+
if (!hasNoindexMeta(html)) {
|
|
102
|
+
throw new CLIError(`El entregable \`${type}\` no trae \`<meta name="robots" content="noindex,nofollow">\`. ` +
|
|
103
|
+
'El template debe emitirlo; no se publica sin él (no lo inyecto).');
|
|
104
|
+
}
|
|
105
|
+
urls[type] = buildUrl(folder, type);
|
|
106
|
+
publishedFolders.add(folder);
|
|
107
|
+
}
|
|
108
|
+
await commitAndPush(hubDir, `publish: ${repo} (${present.join(', ')})`, [...publishedFolders]);
|
|
109
|
+
// Persist sticky state in the derived repo working tree (NO commit here).
|
|
110
|
+
writePublishState(root, {
|
|
111
|
+
repo,
|
|
112
|
+
token: proposalToken,
|
|
113
|
+
urlShape,
|
|
114
|
+
urls,
|
|
115
|
+
publishedAt: new Date().toISOString(),
|
|
116
|
+
});
|
|
117
|
+
if (parsed.flags.commit) {
|
|
118
|
+
await commitDerivedRepo(root, present, repo);
|
|
119
|
+
}
|
|
120
|
+
reportSuccess(present, urls, parsed.flags.commit);
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
process.removeListener('SIGINT', onSignal);
|
|
124
|
+
process.removeListener('SIGTERM', onSignal);
|
|
125
|
+
cleanup();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/** Resolve the sticky token + urlShape, honoring `.publish.json` then the hub. */
|
|
129
|
+
function resolveTokenAndShape(existing, flags, repo, hubDir) {
|
|
130
|
+
if (existing) {
|
|
131
|
+
const wantsOpaque = flags.opaqueUrl;
|
|
132
|
+
const isOpaque = existing.urlShape === 'opaque';
|
|
133
|
+
if (wantsOpaque !== isOpaque && !flags.changeUrl) {
|
|
134
|
+
throw new CLIError(`Esta propuesta ya se publicó como \`${existing.urlShape}\`. Cambiar la forma de URL ` +
|
|
135
|
+
'rompería el link que el cliente ya tiene. Si es a propósito, pasa `--change-url` ' +
|
|
136
|
+
'(la ruta vieja queda huérfana — bájala con `factory unpublish`).');
|
|
137
|
+
}
|
|
138
|
+
const urlShape = flags.changeUrl
|
|
139
|
+
? flags.opaqueUrl
|
|
140
|
+
? 'opaque'
|
|
141
|
+
: 'named'
|
|
142
|
+
: existing.urlShape;
|
|
143
|
+
if (flags.changeUrl && urlShape !== existing.urlShape) {
|
|
144
|
+
console.warn('Aviso: cambiaste la forma de URL. La ruta anterior queda huérfana en el hub ' +
|
|
145
|
+
'(bájala con `factory unpublish` si ya no la quieres).');
|
|
146
|
+
}
|
|
147
|
+
return { proposalToken: existing.token, urlShape };
|
|
148
|
+
}
|
|
149
|
+
// First publish: named → reuse a hub folder if one exists (uniqueness = ownership).
|
|
150
|
+
const urlShape = flags.opaqueUrl ? 'opaque' : 'named';
|
|
151
|
+
if (urlShape === 'named') {
|
|
152
|
+
const hit = findExistingToken(repo, listHubFolders(hubDir));
|
|
153
|
+
if (hit && 'ambiguous' in hit) {
|
|
154
|
+
throw new CLIError(`Hay más de una carpeta \`${repo}-*\` en el hub y no hay \`.publish.json\` local para desambiguar. ` +
|
|
155
|
+
'No adivino cuál es tuya — resuélvelo a mano en el hub o restaura tu `.publish.json`.');
|
|
156
|
+
}
|
|
157
|
+
if (hit)
|
|
158
|
+
return { proposalToken: hit.token, urlShape };
|
|
159
|
+
}
|
|
160
|
+
return { proposalToken: generateToken(), urlShape };
|
|
161
|
+
}
|
|
162
|
+
/** `--commit` opt-in: commit the deliverable + state in the derived repo (pathspec-scoped). */
|
|
163
|
+
async function commitDerivedRepo(root, types, repo) {
|
|
164
|
+
const paths = [...types.map((t) => DELIVERABLE_DIR[t]), path.join('project', '.publish.json')];
|
|
165
|
+
await execa('git', ['add', '--', ...paths], { cwd: root, reject: false });
|
|
166
|
+
const res = await execa('git', ['commit', '-m', `chore: publish ${repo} → ${PROPOSALS_HUB_REPO}`, '--', ...paths], { cwd: root, reject: false });
|
|
167
|
+
if (res.exitCode === 0) {
|
|
168
|
+
console.log('• Commit del entregable + .publish.json hecho en el repo derivado (--commit).');
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
function reportSuccess(types, urls, committed) {
|
|
172
|
+
console.log(`\n✔ Publicado en ${PROPOSALS_HUB_REPO} (Vercel redeploya solo).`);
|
|
173
|
+
for (const t of types) {
|
|
174
|
+
if (urls[t])
|
|
175
|
+
console.log(` ${t}: ${urls[t]}`);
|
|
176
|
+
}
|
|
177
|
+
if (!committed) {
|
|
178
|
+
console.log('\nℹ El entregable + `project/.publish.json` quedaron escritos pero SIN commitear. ' +
|
|
179
|
+
'El workflow los commitea al cerrar; en uso suelto, commitéalos tú (o usa `--commit`).');
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `factory unpublish [proposal|mockup|both]` — take a published deliverable down
|
|
3
|
+
* from the hub (plan v4.1). The recourse against a leaked link in the static,
|
|
4
|
+
* password-less model: it mata el link (cuts future access via the hub), but is
|
|
5
|
+
* contención, NOT recall — anything already fetched by an unfurler lives in third
|
|
6
|
+
* party caches outside our control.
|
|
7
|
+
*
|
|
8
|
+
* Same machinery as publish (auth → shallow clone → mutate → rebase-retry push);
|
|
9
|
+
* no new infra. Keeps the token sticky in `.publish.json` so a later re-publish
|
|
10
|
+
* reuses the same URL.
|
|
11
|
+
*/
|
|
12
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { CLIError } from '../lib/cli-error.js';
|
|
16
|
+
import { PROPOSALS_HUB_REPO } from '../lib/constants.js';
|
|
17
|
+
import { proposalFolder, readPublishState, writePublishState, } from '../lib/publish-core.js';
|
|
18
|
+
import { cloneHubShallow, commitAndPush, removeDeliverable, resolveAuthToken, } from '../lib/publish-engine.js';
|
|
19
|
+
import { detectRepo } from '../lib/repo-detection.js';
|
|
20
|
+
/** Parse the optional `<proposal|mockup|both>` arg (default: both). */
|
|
21
|
+
export function parseUnpublishTargets(argv) {
|
|
22
|
+
const positional = argv.find((a) => !a.startsWith('-'));
|
|
23
|
+
if (!positional || positional === 'both')
|
|
24
|
+
return ['proposal', 'mockup'];
|
|
25
|
+
if (positional === 'proposal' || positional === 'mockup')
|
|
26
|
+
return [positional];
|
|
27
|
+
throw new CLIError(`Argumento inválido: \`${positional}\`. Usa \`proposal\`, \`mockup\` o \`both\`.`);
|
|
28
|
+
}
|
|
29
|
+
export async function runUnpublish(targets) {
|
|
30
|
+
const cwd = process.cwd();
|
|
31
|
+
const { hasRepo, repoRoot } = detectRepo(cwd);
|
|
32
|
+
if (!hasRepo) {
|
|
33
|
+
throw new CLIError('No hay repositorio git aquí. `factory unpublish` corre dentro de un derivado.');
|
|
34
|
+
}
|
|
35
|
+
const root = repoRoot ?? cwd;
|
|
36
|
+
const state = readPublishState(root);
|
|
37
|
+
if (!state) {
|
|
38
|
+
throw new CLIError('No hay `project/.publish.json` en este repo — no sé qué bajar. ' +
|
|
39
|
+
'Esta propuesta no fue publicada desde aquí (o el estado se perdió).');
|
|
40
|
+
}
|
|
41
|
+
const token = await resolveAuthToken();
|
|
42
|
+
const folder = proposalFolder(state.repo, state.token, state.urlShape);
|
|
43
|
+
const tmp = mkdtempSync(path.join(tmpdir(), 'tk-unpublish-'));
|
|
44
|
+
const cleanup = () => {
|
|
45
|
+
try {
|
|
46
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
/* best effort */
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
process.once('SIGINT', cleanup);
|
|
53
|
+
process.once('SIGTERM', cleanup);
|
|
54
|
+
try {
|
|
55
|
+
const hubDir = path.join(tmp, 'hub');
|
|
56
|
+
await cloneHubShallow(token, hubDir);
|
|
57
|
+
const removed = [];
|
|
58
|
+
for (const type of targets) {
|
|
59
|
+
if (removeDeliverable(hubDir, folder, type))
|
|
60
|
+
removed.push(type);
|
|
61
|
+
}
|
|
62
|
+
if (removed.length === 0) {
|
|
63
|
+
console.log('No había nada que bajar en el hub para esta propuesta. Nada que hacer.');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
await commitAndPush(hubDir, `unpublish: ${state.repo} (${removed.join(', ')})`, [folder]);
|
|
67
|
+
// Drop the removed URLs from the sticky state (keep the token for re-publish).
|
|
68
|
+
const urls = { ...state.urls };
|
|
69
|
+
for (const type of removed)
|
|
70
|
+
delete urls[type];
|
|
71
|
+
writePublishState(root, { ...state, urls, publishedAt: new Date().toISOString() });
|
|
72
|
+
console.log(`\n✔ Bajado de ${PROPOSALS_HUB_REPO}: ${removed.join(', ')}.`);
|
|
73
|
+
console.log('ℹ Esto corta el acceso futuro vía el hub, pero NO recupera lo ya filtrado ' +
|
|
74
|
+
'(un link reenviado pudo quedar cacheado por terceros).');
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
process.removeListener('SIGINT', cleanup);
|
|
78
|
+
process.removeListener('SIGTERM', cleanup);
|
|
79
|
+
cleanup();
|
|
80
|
+
}
|
|
81
|
+
}
|
package/dist/commands/update.js
CHANGED
|
@@ -33,11 +33,12 @@
|
|
|
33
33
|
* `update` NEVER touches `src/` — files outside the lockfile/manifest are
|
|
34
34
|
* invisible (design §9 out-of-scope).
|
|
35
35
|
*/
|
|
36
|
-
import { existsSync, mkdtempSync, readFileSync,
|
|
36
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs';
|
|
37
37
|
import { tmpdir } from 'node:os';
|
|
38
38
|
import path from 'node:path';
|
|
39
39
|
import prompts from 'prompts';
|
|
40
40
|
import { applyPlan, clearUpdateState, defaultBackupDir, hasUpdateState, readUpdateState, validateStagedManifest, writeUpdateState, } from '../lib/atomic-swap.js';
|
|
41
|
+
import { collectClaudePaths } from '../lib/claude-paths.js';
|
|
41
42
|
import { CLIError } from '../lib/cli-error.js';
|
|
42
43
|
import { CLAUDE_MD_FILE, PROFILES } from '../lib/constants.js';
|
|
43
44
|
import { diffLockfiles, hasLockfile, normalizeThenHash, planAutoRegister, readLockfile, writeLockfile, } from '../lib/lockfile.js';
|
|
@@ -64,30 +65,6 @@ async function acquireFromRelease(profile) {
|
|
|
64
65
|
const manifest = validateStagedManifest(stagedDir, manifestRaw);
|
|
65
66
|
return { stagedDir, manifest, tmpRoot };
|
|
66
67
|
}
|
|
67
|
-
/**
|
|
68
|
-
* Recursively collect repo-relative paths of files under `.claude/` plus any
|
|
69
|
-
* tracked root files the manifest references at the top level. Used both for
|
|
70
|
-
* disk hashing and auto-register path-match. Only descends into `.claude/`;
|
|
71
|
-
* `src/` and everything else is invisible (design §7.2).
|
|
72
|
-
*/
|
|
73
|
-
function collectClaudePaths(rootDir) {
|
|
74
|
-
const out = [];
|
|
75
|
-
const walk = (rel) => {
|
|
76
|
-
const abs = path.join(rootDir, rel);
|
|
77
|
-
if (!existsSync(abs))
|
|
78
|
-
return;
|
|
79
|
-
if (statSync(abs).isDirectory()) {
|
|
80
|
-
for (const entry of readdirSync(abs)) {
|
|
81
|
-
walk(path.posix.join(rel, entry));
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
else {
|
|
85
|
-
out.push(rel);
|
|
86
|
-
}
|
|
87
|
-
};
|
|
88
|
-
walk('.claude');
|
|
89
|
-
return out;
|
|
90
|
-
}
|
|
91
68
|
/**
|
|
92
69
|
* Hash the disk content (normalized) of every path in `paths` that exists.
|
|
93
70
|
* A missing file is simply absent from the returned map.
|
package/dist/index.js
CHANGED
|
@@ -13,7 +13,9 @@ import { CLIError } from './lib/cli-error.js';
|
|
|
13
13
|
import { parseAddFlags, runAdd } from './commands/add.js';
|
|
14
14
|
import { runDoctor } from './commands/doctor.js';
|
|
15
15
|
import { runNew } from './commands/new.js';
|
|
16
|
+
import { parsePublishArgs, runPublish } from './commands/publish.js';
|
|
16
17
|
import { runStatus } from './commands/status.js';
|
|
18
|
+
import { parseUnpublishTargets, runUnpublish } from './commands/unpublish.js';
|
|
17
19
|
import { parseUpdateFlags, runUpdate } from './commands/update.js';
|
|
18
20
|
const HELP = `
|
|
19
21
|
@timekast/factory — bootstrap y mantenimiento de proyectos derivados del Factory.
|
|
@@ -27,6 +29,8 @@ Comandos:
|
|
|
27
29
|
update Actualiza el cerebro al día sin pisar tu trabajo local
|
|
28
30
|
status Reporta la versión instalada vs. la última disponible
|
|
29
31
|
doctor Detecta huérfanos, conflictos y rules sin importar + aviso de seguridad
|
|
32
|
+
publish <qué> Publica el entregable (proposal|mockup|both) a proposals.timekast.mx
|
|
33
|
+
unpublish [qué] Baja del hub un entregable publicado (proposal|mockup|both; default both)
|
|
30
34
|
|
|
31
35
|
Sobre \`add\`:
|
|
32
36
|
Requiere estar dentro de un repo git (al menos \`git init\`). Por default instala
|
|
@@ -107,6 +111,14 @@ async function main(argv) {
|
|
|
107
111
|
runDoctor();
|
|
108
112
|
return;
|
|
109
113
|
}
|
|
114
|
+
case 'publish': {
|
|
115
|
+
await runPublish(parsePublishArgs(rest));
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
case 'unpublish': {
|
|
119
|
+
await runUnpublish(parseUnpublishTargets(rest));
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
110
122
|
default:
|
|
111
123
|
throw new CLIError(`Comando desconocido: \`${command}\`.\nEjecuta \`factory --help\` para ver los comandos disponibles.`);
|
|
112
124
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collect the repo-relative POSIX paths of files under `.claude/`, EXCLUDING
|
|
3
|
+
* gitignored ones. Shared by `update` (legacy auto-register ambiguous report)
|
|
4
|
+
* and `doctor` (orphan report) so neither flags a dev-local, gitignored file
|
|
5
|
+
* (`settings.local.json`, `transitions/`, `.DS_Store`) as "ambiguous"/"orphan" —
|
|
6
|
+
* those are never kit-managed, so surfacing them is pure noise.
|
|
7
|
+
*
|
|
8
|
+
* Only descends into `.claude/`; `src/` and everything else is invisible
|
|
9
|
+
* (design §7.2).
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { spawnSync } from 'node:child_process';
|
|
14
|
+
/**
|
|
15
|
+
* Fallback ignore set, used only when `git check-ignore` is unavailable (no git
|
|
16
|
+
* binary, or the dir is not a git repo). Mirrors the kit's own `.gitignore`
|
|
17
|
+
* entries for `.claude/`. The git path is authoritative when present.
|
|
18
|
+
*/
|
|
19
|
+
const IGNORE_FALLBACK_RE = /(^|\/)(\.DS_Store|settings\.local\.json)$|(^|\/)transitions\//;
|
|
20
|
+
/** Recursively gather repo-relative POSIX paths of files under `.claude/`. */
|
|
21
|
+
function walkClaude(rootDir) {
|
|
22
|
+
const out = [];
|
|
23
|
+
const walk = (rel) => {
|
|
24
|
+
const abs = path.join(rootDir, rel);
|
|
25
|
+
if (!existsSync(abs))
|
|
26
|
+
return;
|
|
27
|
+
if (statSync(abs).isDirectory()) {
|
|
28
|
+
for (const entry of readdirSync(abs))
|
|
29
|
+
walk(path.posix.join(rel, entry));
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
out.push(rel);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
walk('.claude');
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Drop the gitignored paths from `paths`. Uses `git check-ignore --stdin` (the
|
|
40
|
+
* authoritative source — respects nested `.gitignore`, negations, etc.); falls
|
|
41
|
+
* back to `IGNORE_FALLBACK_RE` when git is missing or the dir is not a repo.
|
|
42
|
+
*
|
|
43
|
+
* `git check-ignore` exit codes: 0 = some paths matched (printed), 1 = none
|
|
44
|
+
* matched (empty output), 128 = error / not a repo. `spawnSync` does not throw,
|
|
45
|
+
* so 0 and 1 both yield usable stdout; >1 or a spawn error → fallback.
|
|
46
|
+
*/
|
|
47
|
+
function dropGitignored(rootDir, paths) {
|
|
48
|
+
if (paths.length === 0)
|
|
49
|
+
return paths;
|
|
50
|
+
const res = spawnSync('git', ['-C', rootDir, 'check-ignore', '--stdin'], {
|
|
51
|
+
input: paths.join('\n'),
|
|
52
|
+
encoding: 'utf8',
|
|
53
|
+
});
|
|
54
|
+
if (res.error || res.status === null || res.status > 1) {
|
|
55
|
+
return paths.filter((p) => !IGNORE_FALLBACK_RE.test(p));
|
|
56
|
+
}
|
|
57
|
+
const ignored = new Set((res.stdout || '')
|
|
58
|
+
.split('\n')
|
|
59
|
+
.map((l) => l.trim())
|
|
60
|
+
.filter(Boolean));
|
|
61
|
+
return paths.filter((p) => !ignored.has(p));
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Repo-relative POSIX paths of NON-gitignored files under `.claude/`.
|
|
65
|
+
*/
|
|
66
|
+
export function collectClaudePaths(rootDir) {
|
|
67
|
+
return dropGitignored(rootDir, walkClaude(rootDir));
|
|
68
|
+
}
|
package/dist/lib/constants.js
CHANGED
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
export const FACTORY_ORG = 'TimeKast';
|
|
8
8
|
/** The Factory monorepo, source of the distribution releases. */
|
|
9
9
|
export const FACTORY_REPO = 'TimeKast/TimeKast-Factory';
|
|
10
|
+
/** The static hub repo that hosts published proposals/mockups (`factory publish`). */
|
|
11
|
+
export const PROPOSALS_HUB_REPO = `${FACTORY_ORG}/proposals`;
|
|
12
|
+
/** HTTPS git URL of the hub (token is injected by the publish command). */
|
|
13
|
+
export const PROPOSALS_HUB_GIT = `https://github.com/${FACTORY_ORG}/proposals.git`;
|
|
10
14
|
/** Distribution profiles selectable in `new`. */
|
|
11
15
|
export const PROFILES = {
|
|
12
16
|
full: 'full',
|
|
@@ -26,9 +30,30 @@ export const LOCKFILE_FILE = 'lockfile.json';
|
|
|
26
30
|
* exists on disk (path-match path). See `diffLockfiles` + the install commands.
|
|
27
31
|
*/
|
|
28
32
|
export const CLAUDE_MD_FILE = 'CLAUDE.md';
|
|
29
|
-
/** The
|
|
33
|
+
/** The scripts the CLI injects into a derived project's package.json. */
|
|
30
34
|
export const UPDATE_SCRIPT_NAME = 'factory:update';
|
|
31
35
|
// `npx` so the script resolves in a fresh derived repo where @timekast/factory
|
|
32
36
|
// is NOT a dependency (B3): a bare `@timekast/factory update` would be
|
|
33
37
|
// "command not found". npx resolves the published package on demand.
|
|
34
38
|
export const UPDATE_SCRIPT_CMD = 'npx @timekast/factory update';
|
|
39
|
+
export const DOCTOR_SCRIPT_NAME = 'factory:doctor';
|
|
40
|
+
export const DOCTOR_SCRIPT_CMD = 'npx @timekast/factory doctor';
|
|
41
|
+
export const STATUS_SCRIPT_NAME = 'factory:status';
|
|
42
|
+
export const STATUS_SCRIPT_CMD = 'npx @timekast/factory status';
|
|
43
|
+
export const PUBLISH_SCRIPT_NAME = 'factory:publish';
|
|
44
|
+
export const PUBLISH_SCRIPT_CMD = 'npx @timekast/factory publish';
|
|
45
|
+
export const UNPUBLISH_SCRIPT_NAME = 'factory:unpublish';
|
|
46
|
+
export const UNPUBLISH_SCRIPT_CMD = 'npx @timekast/factory unpublish';
|
|
47
|
+
/**
|
|
48
|
+
* All convenience scripts the installer ensures in a Node derivative's
|
|
49
|
+
* package.json (each: add if missing, never overwrite a divergent value). Keyed
|
|
50
|
+
* by script name → command. `factory:update` is the primary (drives the install
|
|
51
|
+
* messaging); `doctor`/`status` are read-only diagnostics with no other alias.
|
|
52
|
+
*/
|
|
53
|
+
export const FACTORY_SCRIPTS = {
|
|
54
|
+
[UPDATE_SCRIPT_NAME]: UPDATE_SCRIPT_CMD,
|
|
55
|
+
[DOCTOR_SCRIPT_NAME]: DOCTOR_SCRIPT_CMD,
|
|
56
|
+
[STATUS_SCRIPT_NAME]: STATUS_SCRIPT_CMD,
|
|
57
|
+
[PUBLISH_SCRIPT_NAME]: PUBLISH_SCRIPT_CMD,
|
|
58
|
+
[UNPUBLISH_SCRIPT_NAME]: UNPUBLISH_SCRIPT_CMD,
|
|
59
|
+
};
|
package/dist/lib/package-json.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import { CLIError } from './cli-error.js';
|
|
11
|
-
import {
|
|
11
|
+
import { FACTORY_SCRIPTS, PROFILES, UPDATE_SCRIPT_NAME } from './constants.js';
|
|
12
12
|
/**
|
|
13
13
|
* Auto-detect the profile to install into an existing repo from its
|
|
14
14
|
* `package.json`: a `factoryVersion` field means the repo was born from the
|
|
@@ -58,26 +58,40 @@ export function parsePackageJson(raw) {
|
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
60
|
/**
|
|
61
|
-
* Pure core of the script insertion: mutate `pkg.scripts` in place to ensure
|
|
62
|
-
* `factory
|
|
61
|
+
* Pure core of the script insertion: mutate `pkg.scripts` in place to ensure ALL
|
|
62
|
+
* `factory:*` convenience scripts exist (`update` / `doctor` / `status`), adding
|
|
63
|
+
* each if missing and NEVER overwriting a divergent value (§7.4). Shared by
|
|
63
64
|
* `applyPackageJsonEdits` (the `new` flow) and `insertFactoryUpdateScript`
|
|
64
|
-
* (the `update` flow)
|
|
65
|
+
* (the `update` flow). Returns whether anything changed (so the caller writes
|
|
66
|
+
* only on a real edit) + the result for the primary `factory:update` script
|
|
67
|
+
* (which drives the install messaging).
|
|
65
68
|
*/
|
|
66
|
-
function
|
|
69
|
+
function ensureFactoryScripts(pkg) {
|
|
67
70
|
const scripts = pkg.scripts ?? {};
|
|
68
|
-
const existing = scripts[UPDATE_SCRIPT_NAME];
|
|
69
71
|
pkg.scripts = scripts;
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
72
|
+
let changed = false;
|
|
73
|
+
let primary = { action: 'already-correct' };
|
|
74
|
+
for (const [name, cmd] of Object.entries(FACTORY_SCRIPTS)) {
|
|
75
|
+
const existing = scripts[name];
|
|
76
|
+
let result;
|
|
77
|
+
if (existing === undefined) {
|
|
78
|
+
scripts[name] = cmd;
|
|
79
|
+
changed = true;
|
|
80
|
+
result = { action: 'added' };
|
|
81
|
+
}
|
|
82
|
+
else if (existing === cmd) {
|
|
83
|
+
result = { action: 'already-correct' };
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
result = { action: 'conflict', existingValue: existing };
|
|
87
|
+
}
|
|
88
|
+
if (name === UPDATE_SCRIPT_NAME)
|
|
89
|
+
primary = result;
|
|
76
90
|
}
|
|
77
|
-
return {
|
|
91
|
+
return { changed, primary };
|
|
78
92
|
}
|
|
79
93
|
/**
|
|
80
|
-
* Rename `package.json.name` and ensure the `factory
|
|
94
|
+
* Rename `package.json.name` and ensure the `factory:*` scripts exist,
|
|
81
95
|
* preserving everything else. Returns the serialized document (with the
|
|
82
96
|
* original indentation + trailing newline).
|
|
83
97
|
*
|
|
@@ -88,31 +102,32 @@ export function applyPackageJsonEdits(raw, name) {
|
|
|
88
102
|
const indent = detectIndent(raw);
|
|
89
103
|
const pkg = parsePackageJson(raw);
|
|
90
104
|
pkg.name = name;
|
|
91
|
-
const
|
|
105
|
+
const { primary } = ensureFactoryScripts(pkg);
|
|
92
106
|
const content = `${JSON.stringify(pkg, null, indent)}\n`;
|
|
93
|
-
return { content, scriptAlreadyPresent:
|
|
107
|
+
return { content, scriptAlreadyPresent: primary.action === 'conflict' };
|
|
94
108
|
}
|
|
95
109
|
/**
|
|
96
|
-
* Surgically ensure the `factory
|
|
97
|
-
* `pkgPath` (design §7.4). Reads, mutates only
|
|
98
|
-
* with the original indentation + trailing
|
|
99
|
-
* value and NEVER touches `name`, deps,
|
|
110
|
+
* Surgically ensure the `factory:*` scripts (`update` / `doctor` / `status`)
|
|
111
|
+
* exist in the `package.json` at `pkgPath` (design §7.4). Reads, mutates only
|
|
112
|
+
* those keys, and re-serializes with the original indentation + trailing
|
|
113
|
+
* newline. NEVER overwrites a divergent value and NEVER touches `name`, deps,
|
|
114
|
+
* or any other script.
|
|
100
115
|
*
|
|
101
|
-
* Writes the file only when
|
|
102
|
-
*
|
|
116
|
+
* Writes the file only when at least one script was added; if all are already
|
|
117
|
+
* present (correct or divergent) the file is left byte-identical.
|
|
103
118
|
*
|
|
104
119
|
* @param pkgPath Absolute path to the derived project's `package.json`.
|
|
105
|
-
* @returns
|
|
120
|
+
* @returns The result for the primary `factory:update` script (+ existing value on conflict).
|
|
106
121
|
*/
|
|
107
122
|
export function insertFactoryUpdateScript(pkgPath) {
|
|
108
123
|
const raw = readFileSync(pkgPath, 'utf8');
|
|
109
124
|
const indent = detectIndent(raw);
|
|
110
125
|
const pkg = parsePackageJson(raw);
|
|
111
|
-
const
|
|
112
|
-
if (
|
|
126
|
+
const { changed, primary } = ensureFactoryScripts(pkg);
|
|
127
|
+
if (changed) {
|
|
113
128
|
writeFileSync(pkgPath, `${JSON.stringify(pkg, null, indent)}\n`, 'utf8');
|
|
114
129
|
}
|
|
115
|
-
return
|
|
130
|
+
return primary;
|
|
116
131
|
}
|
|
117
132
|
/**
|
|
118
133
|
* Surgically set the derived project's `package.json.agentKitVersion` to the
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for `factory publish` / `factory unpublish` (kept side-effect-free
|
|
3
|
+
* where possible so they unit-test without mocks). The git/gh/network seams live
|
|
4
|
+
* in the commands; everything here is deterministic given its inputs.
|
|
5
|
+
*
|
|
6
|
+
* Identity model (plan v4.1): a proposal's hub folder is `{repo}-{token}` where
|
|
7
|
+
* `repo` is the derived project's repo name (unique within the org → ownership is
|
|
8
|
+
* free) and `token` is an unguessable suffix. `--opaque-url` drops the repo name
|
|
9
|
+
* (`p-{token}`) for clients whose name must not travel in the link.
|
|
10
|
+
*/
|
|
11
|
+
import { randomBytes } from 'node:crypto';
|
|
12
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
/** Source dir (relative to repo root) for each deliverable. */
|
|
15
|
+
export const DELIVERABLE_DIR = {
|
|
16
|
+
proposal: path.join('project', 'presentation'),
|
|
17
|
+
mockup: path.join('project', 'mockup'),
|
|
18
|
+
};
|
|
19
|
+
/** The public domain the hub serves under. */
|
|
20
|
+
export const PROPOSALS_DOMAIN = 'proposals.timekast.mx';
|
|
21
|
+
/** Relative path (from repo root) to the persisted publish state. */
|
|
22
|
+
export const PUBLISH_STATE_FILE = path.join('project', '.publish.json');
|
|
23
|
+
const TOKEN_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789'; // base36 — URL-safe, case-insensitive
|
|
24
|
+
const TOKEN_LENGTH = 16;
|
|
25
|
+
/**
|
|
26
|
+
* Generate an unguessable token (16 base36 chars ≈ 82 bits). Not a secret in the
|
|
27
|
+
* access-control sense — it makes the URL non-guessable and prevents enumeration.
|
|
28
|
+
*/
|
|
29
|
+
export function generateToken() {
|
|
30
|
+
const bytes = randomBytes(TOKEN_LENGTH);
|
|
31
|
+
let out = '';
|
|
32
|
+
for (let i = 0; i < TOKEN_LENGTH; i++) {
|
|
33
|
+
out += TOKEN_ALPHABET[bytes[i] % TOKEN_ALPHABET.length];
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Parse the repo name from a git remote URL (ssh or https forms), e.g.
|
|
39
|
+
* `git@github.com:TimeKast/fimubac.git` / `https://github.com/TimeKast/fimubac` → `fimubac`.
|
|
40
|
+
* Returns null when the URL has no recognizable `…/repo` tail.
|
|
41
|
+
*/
|
|
42
|
+
export function parseRepoSlug(remoteUrl) {
|
|
43
|
+
const trimmed = remoteUrl.trim().replace(/\.git$/, '');
|
|
44
|
+
// Last path segment after `/` or `:` (ssh scp-like form uses `:`).
|
|
45
|
+
const match = trimmed.match(/[/:]([^/:]+)$/);
|
|
46
|
+
const name = match?.[1];
|
|
47
|
+
if (!name)
|
|
48
|
+
return null;
|
|
49
|
+
const slug = slugify(name);
|
|
50
|
+
return slug || null;
|
|
51
|
+
}
|
|
52
|
+
/** Lowercase + collapse non-alphanumerics to single dashes; trim dashes. */
|
|
53
|
+
export function slugify(input) {
|
|
54
|
+
return input
|
|
55
|
+
.toLowerCase()
|
|
56
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
57
|
+
.replace(/^-+|-+$/g, '');
|
|
58
|
+
}
|
|
59
|
+
/** The hub folder name for a proposal, given its identity + URL shape. */
|
|
60
|
+
export function proposalFolder(repo, token, shape) {
|
|
61
|
+
return shape === 'opaque' ? `p-${token}` : `${repo}-${token}`;
|
|
62
|
+
}
|
|
63
|
+
/** The public URL for a published deliverable (trailing slash → relative assets resolve). */
|
|
64
|
+
export function buildUrl(folder, type) {
|
|
65
|
+
return `https://${PROPOSALS_DOMAIN}/${folder}/${type}/`;
|
|
66
|
+
}
|
|
67
|
+
/** True when the HTML carries a `<meta name="robots" … noindex …>` (order-tolerant). */
|
|
68
|
+
export function hasNoindexMeta(html) {
|
|
69
|
+
const nameThenContent = /<meta[^>]+name=["']robots["'][^>]*content=["'][^"']*noindex[^"']*["']/i;
|
|
70
|
+
const contentThenName = /<meta[^>]+content=["'][^"']*noindex[^"']*["'][^>]*name=["']robots["']/i;
|
|
71
|
+
return nameThenContent.test(html) || contentThenName.test(html);
|
|
72
|
+
}
|
|
73
|
+
/** True when the deliverable's source dir exists in the derived repo. */
|
|
74
|
+
export function deliverableExists(root, type) {
|
|
75
|
+
return existsSync(path.join(root, DELIVERABLE_DIR[type]));
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Resolve the HTML entry filename inside a deliverable source dir. Mockups ship
|
|
79
|
+
* `index.html`; proposals ship `{slug}-proposal.html`. Returns the filename to
|
|
80
|
+
* rename to `index.html` in the hub, or null when no HTML entry is found.
|
|
81
|
+
*/
|
|
82
|
+
export function findHtmlEntry(files) {
|
|
83
|
+
if (files.includes('index.html'))
|
|
84
|
+
return 'index.html';
|
|
85
|
+
const proposal = files.find((f) => /-proposal\.html$/.test(f));
|
|
86
|
+
if (proposal)
|
|
87
|
+
return proposal;
|
|
88
|
+
const anyHtml = files.find((f) => f.endsWith('.html'));
|
|
89
|
+
return anyHtml ?? null;
|
|
90
|
+
}
|
|
91
|
+
/** Read the persisted publish state, or null when absent / unparseable. */
|
|
92
|
+
export function readPublishState(root) {
|
|
93
|
+
const file = path.join(root, PUBLISH_STATE_FILE);
|
|
94
|
+
if (!existsSync(file))
|
|
95
|
+
return null;
|
|
96
|
+
try {
|
|
97
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/** Write the persisted publish state (pretty-printed, trailing newline). */
|
|
104
|
+
export function writePublishState(root, state) {
|
|
105
|
+
const file = path.join(root, PUBLISH_STATE_FILE);
|
|
106
|
+
writeFileSync(file, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Find an existing folder for `repo` among the hub's top-level entries (named
|
|
110
|
+
* shape only — opaque folders carry no repo name, so they rely on local state).
|
|
111
|
+
* Because the repo name is unique in the org, any `{repo}-*` match is ours.
|
|
112
|
+
* Returns the token, or null when none / ambiguous (>1 → caller confirms).
|
|
113
|
+
*/
|
|
114
|
+
export function findExistingToken(repo, hubEntries) {
|
|
115
|
+
const prefix = `${repo}-`;
|
|
116
|
+
const matches = hubEntries.filter((e) => e.startsWith(prefix));
|
|
117
|
+
if (matches.length === 0)
|
|
118
|
+
return null;
|
|
119
|
+
if (matches.length > 1)
|
|
120
|
+
return { ambiguous: true };
|
|
121
|
+
return { token: matches[0].slice(prefix.length) };
|
|
122
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* I/O seams for `factory publish` / `factory unpublish`: git + gh + filesystem.
|
|
3
|
+
* Kept apart from the pure helpers (`publish-core.ts`) and the orchestration
|
|
4
|
+
* (`commands/*.ts`) so the concurrency-sensitive bits (auth, shallow clone,
|
|
5
|
+
* rebase-retry push) test against an injectable git runner.
|
|
6
|
+
*/
|
|
7
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, renameSync, rmSync, statSync } from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { execa } from 'execa';
|
|
10
|
+
import { CLIError } from './cli-error.js';
|
|
11
|
+
import { FACTORY_ORG } from './constants.js';
|
|
12
|
+
import { runPreflight } from './preflight.js';
|
|
13
|
+
/** Default git runner (never throws on non-zero — callers inspect `exitCode`). */
|
|
14
|
+
export const realGit = async (args, opts) => {
|
|
15
|
+
const res = await execa('git', args, { cwd: opts?.cwd, reject: false });
|
|
16
|
+
return { stdout: res.stdout ?? '', exitCode: res.exitCode ?? 1 };
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Resolve a token with push access to the hub. Dual auth (plan B-1):
|
|
20
|
+
* - `TIMEKAST_PUBLISH_TOKEN` in env → headless (Agent Server, infra TimeKast).
|
|
21
|
+
* - else → dev local: full preflight (gh + org member) then `gh auth token`.
|
|
22
|
+
* Never reads a token from a derived repo.
|
|
23
|
+
*/
|
|
24
|
+
export async function resolveAuthToken() {
|
|
25
|
+
const envToken = process.env.TIMEKAST_PUBLISH_TOKEN?.trim();
|
|
26
|
+
if (envToken)
|
|
27
|
+
return envToken;
|
|
28
|
+
await runPreflight();
|
|
29
|
+
const res = await execa('gh', ['auth', 'token'], { reject: false });
|
|
30
|
+
const token = (res.stdout ?? '').trim();
|
|
31
|
+
if (res.exitCode !== 0 || !token) {
|
|
32
|
+
throw new CLIError('No se pudo obtener un token de GitHub con `gh auth token`. Verifica `gh auth status`.');
|
|
33
|
+
}
|
|
34
|
+
return token;
|
|
35
|
+
}
|
|
36
|
+
/** The hub clone URL with an embedded token (lives only in the ephemeral tmpdir). */
|
|
37
|
+
function hubCloneUrl(token) {
|
|
38
|
+
return `https://x-access-token:${token}@github.com/${FACTORY_ORG}/proposals.git`;
|
|
39
|
+
}
|
|
40
|
+
/** Shallow-clone the hub into `destDir` (no history, no other proposals' assets). */
|
|
41
|
+
export async function cloneHubShallow(token, destDir) {
|
|
42
|
+
const res = await execa('git', ['clone', '--depth', '1', hubCloneUrl(token), destDir], {
|
|
43
|
+
reject: false,
|
|
44
|
+
});
|
|
45
|
+
if (res.exitCode !== 0) {
|
|
46
|
+
throw new CLIError(`No se pudo clonar el hub \`${FACTORY_ORG}/proposals\`. ` +
|
|
47
|
+
'Verifica que el repo exista y que tengas acceso de escritura.\n' +
|
|
48
|
+
(res.stderr ?? ''));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** List the hub's top-level proposal folders (excludes files + `.git`). */
|
|
52
|
+
export function listHubFolders(cloneDir) {
|
|
53
|
+
return readdirSync(cloneDir)
|
|
54
|
+
.filter((e) => e !== '.git')
|
|
55
|
+
.filter((e) => {
|
|
56
|
+
try {
|
|
57
|
+
return statSync(path.join(cloneDir, e)).isDirectory();
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Copy a deliverable source dir into the hub clone at `{folder}/{type}`, replacing
|
|
66
|
+
* any prior content (kills orphans), and rename the HTML entry to `index.html`.
|
|
67
|
+
* Returns the absolute path of the copied `index.html`.
|
|
68
|
+
*/
|
|
69
|
+
export function copyDeliverable(srcDir, cloneDir, folder, type, htmlEntry) {
|
|
70
|
+
const destDir = path.join(cloneDir, folder, type);
|
|
71
|
+
rmSync(destDir, { recursive: true, force: true });
|
|
72
|
+
mkdirSync(destDir, { recursive: true });
|
|
73
|
+
cpSync(srcDir, destDir, { recursive: true });
|
|
74
|
+
const indexPath = path.join(destDir, 'index.html');
|
|
75
|
+
if (htmlEntry !== 'index.html') {
|
|
76
|
+
renameSync(path.join(destDir, htmlEntry), indexPath);
|
|
77
|
+
}
|
|
78
|
+
return indexPath;
|
|
79
|
+
}
|
|
80
|
+
/** Remove a deliverable folder/type from the hub clone (for `unpublish`). */
|
|
81
|
+
export function removeDeliverable(cloneDir, folder, type) {
|
|
82
|
+
const target = path.join(cloneDir, folder, type);
|
|
83
|
+
if (!existsSync(target))
|
|
84
|
+
return false;
|
|
85
|
+
rmSync(target, { recursive: true, force: true });
|
|
86
|
+
// Prune the now-empty parent folder if no sibling deliverable remains.
|
|
87
|
+
const parent = path.join(cloneDir, folder);
|
|
88
|
+
try {
|
|
89
|
+
if (readdirSync(parent).length === 0)
|
|
90
|
+
rmSync(parent, { recursive: true, force: true });
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
/* best effort */
|
|
94
|
+
}
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
const MAX_PUSH_ATTEMPTS = 3;
|
|
98
|
+
/**
|
|
99
|
+
* Stage the given paths, commit, and push — with bounded rebase-retry to survive
|
|
100
|
+
* concurrent publishes (a second push racing the first gets `non-fast-forward`;
|
|
101
|
+
* we `pull --rebase` and retry). Uses an explicit pathspec on `add` so a dev's
|
|
102
|
+
* unrelated staged work is never swept in. No-op when nothing changed.
|
|
103
|
+
*/
|
|
104
|
+
export async function commitAndPush(cloneDir, message, paths, git = realGit) {
|
|
105
|
+
await git(['add', '--', ...paths], { cwd: cloneDir });
|
|
106
|
+
const status = await git(['status', '--porcelain'], { cwd: cloneDir });
|
|
107
|
+
if (!status.stdout.trim())
|
|
108
|
+
return; // nothing to publish — content identical
|
|
109
|
+
const commit = await git(['commit', '-m', message], { cwd: cloneDir });
|
|
110
|
+
if (commit.exitCode !== 0) {
|
|
111
|
+
throw new CLIError(`No se pudo crear el commit en el hub.\n${commit.stdout}`);
|
|
112
|
+
}
|
|
113
|
+
for (let attempt = 1; attempt <= MAX_PUSH_ATTEMPTS; attempt++) {
|
|
114
|
+
const push = await git(['push'], { cwd: cloneDir });
|
|
115
|
+
if (push.exitCode === 0)
|
|
116
|
+
return;
|
|
117
|
+
if (attempt === MAX_PUSH_ATTEMPTS)
|
|
118
|
+
break;
|
|
119
|
+
const rebase = await git(['pull', '--rebase'], { cwd: cloneDir });
|
|
120
|
+
if (rebase.exitCode !== 0) {
|
|
121
|
+
throw new CLIError('No se pudo rebasar sobre el hub remoto (conflicto inesperado). ' +
|
|
122
|
+
'Reintenta `factory publish`; si persiste, revisa el repo del hub.');
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
throw new CLIError(`No se pudo pushear al hub tras ${MAX_PUSH_ATTEMPTS} intentos (mucha concurrencia). Reintenta.`);
|
|
126
|
+
}
|
|
127
|
+
/** Resolve the `origin` remote URL of a repo, or null when there is no remote. */
|
|
128
|
+
export async function gitRemoteUrl(root) {
|
|
129
|
+
const res = await execa('git', ['remote', 'get-url', 'origin'], { cwd: root, reject: false });
|
|
130
|
+
if (res.exitCode !== 0)
|
|
131
|
+
return null;
|
|
132
|
+
return res.stdout.trim() || null;
|
|
133
|
+
}
|