@paulojalowyj/openkit 0.1.1 → 0.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/README.md +13 -0
- package/README.pt-BR.md +13 -0
- package/bin/cli.js +152 -3
- package/bin/lib/upgrade/applier.js +87 -0
- package/bin/lib/upgrade/index.js +215 -0
- package/bin/lib/upgrade/init-manifest.js +47 -0
- package/bin/lib/upgrade/manifest.js +86 -0
- package/bin/lib/upgrade/paths.js +55 -0
- package/bin/lib/upgrade/planner.js +67 -0
- package/bin/lib/upgrade/sha256.js +6 -0
- package/bin/lib/upgrade/template.js +56 -0
- package/bin/lib/upgrade.js +250 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -30,6 +30,19 @@ npx @paulojalowyj/openkit init --blueprint fullstack
|
|
|
30
30
|
# (restart OpenCode TUI and use / commands)
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
+
## Upgrade
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
# Preview changes (no writes)
|
|
37
|
+
npx @paulojalowyj/openkit upgrade --dry-run
|
|
38
|
+
|
|
39
|
+
# Apply safe upgrades (non-interactive defaults to skipping conflicts)
|
|
40
|
+
npx @paulojalowyj/openkit upgrade
|
|
41
|
+
|
|
42
|
+
# CI mode: fail job when customizations/conflicts exist
|
|
43
|
+
npx @paulojalowyj/openkit upgrade --fail-on-changes
|
|
44
|
+
```
|
|
45
|
+
|
|
33
46
|
## About OpenCode
|
|
34
47
|
|
|
35
48
|
OpenCode is a terminal-based AI coding agent that OpenKit uses to execute commands and manage agents.
|
package/README.pt-BR.md
CHANGED
|
@@ -29,6 +29,19 @@ npx @paulojalowyj/openkit init --blueprint fullstack
|
|
|
29
29
|
# (execute `opencode` no seu projeto e use os comandos /)
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
+
## Upgrade
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
# Ver o plano (sem escrever arquivos)
|
|
36
|
+
npx @paulojalowyj/openkit upgrade --dry-run
|
|
37
|
+
|
|
38
|
+
# Aplicar upgrades seguros (sem TTY, conflitos sao pulados por default)
|
|
39
|
+
npx @paulojalowyj/openkit upgrade
|
|
40
|
+
|
|
41
|
+
# CI: falhar o job quando houver customizacoes/conflitos
|
|
42
|
+
npx @paulojalowyj/openkit upgrade --fail-on-changes
|
|
43
|
+
```
|
|
44
|
+
|
|
32
45
|
## Sobre o OpenCode
|
|
33
46
|
|
|
34
47
|
OpenCode é um agente de código AI baseado em terminal que o OpenKit usa para executar comandos e gerenciar agentes.
|
package/bin/cli.js
CHANGED
|
@@ -4,18 +4,30 @@ import { Command } from 'commander';
|
|
|
4
4
|
import chalk from 'chalk';
|
|
5
5
|
import path from 'path';
|
|
6
6
|
import { fileURLToPath } from 'url';
|
|
7
|
-
import { copyFileSync, existsSync, mkdirSync, readdirSync } from 'fs';
|
|
7
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from 'fs';
|
|
8
8
|
import { dirname } from 'path';
|
|
9
9
|
import { execSync } from 'child_process';
|
|
10
10
|
import inquirer from 'inquirer';
|
|
11
11
|
import fs from 'fs-extra';
|
|
12
12
|
import { glob } from 'glob';
|
|
13
|
+
import { writeInitManifest } from './lib/upgrade/init-manifest.js';
|
|
14
|
+
import { runUpgrade } from './lib/upgrade/index.js';
|
|
13
15
|
|
|
14
16
|
const __filename = fileURLToPath(import.meta.url);
|
|
15
17
|
const __dirname = dirname(__filename);
|
|
16
18
|
|
|
17
19
|
const program = new Command();
|
|
18
20
|
|
|
21
|
+
function getOpenkitVersion() {
|
|
22
|
+
try {
|
|
23
|
+
const pkgPath = path.join(__dirname, '..', 'package.json');
|
|
24
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
25
|
+
if (pkg && typeof pkg.version === 'string') return pkg.version;
|
|
26
|
+
} catch {
|
|
27
|
+
}
|
|
28
|
+
return program.version();
|
|
29
|
+
}
|
|
30
|
+
|
|
19
31
|
function checkOpenCodeInstalled() {
|
|
20
32
|
try {
|
|
21
33
|
execSync('which opencode || where opencode', {
|
|
@@ -149,6 +161,10 @@ async function copyDir(src, dest, root = src) {
|
|
|
149
161
|
continue;
|
|
150
162
|
}
|
|
151
163
|
|
|
164
|
+
if (entry.isSymbolicLink()) {
|
|
165
|
+
throw new Error(`Refusing to copy symlink: ${relativePath}`);
|
|
166
|
+
}
|
|
167
|
+
|
|
152
168
|
if (entry.isDirectory()) {
|
|
153
169
|
await copyDir(srcPath, destPath, root);
|
|
154
170
|
} else {
|
|
@@ -317,7 +333,31 @@ async function replacePlaceholders(dir, replacements) {
|
|
|
317
333
|
}
|
|
318
334
|
|
|
319
335
|
async function copyBlueprint(blueprintName, targetDir, replacements) {
|
|
320
|
-
const
|
|
336
|
+
const blueprintsRoot = path.join(__dirname, '..', 'blueprints');
|
|
337
|
+
const availableBlueprints = (() => {
|
|
338
|
+
try {
|
|
339
|
+
return readdirSync(blueprintsRoot, { withFileTypes: true })
|
|
340
|
+
.filter((e) => e.isDirectory())
|
|
341
|
+
.map((e) => e.name);
|
|
342
|
+
} catch {
|
|
343
|
+
return [];
|
|
344
|
+
}
|
|
345
|
+
})();
|
|
346
|
+
|
|
347
|
+
if (!blueprintName || typeof blueprintName !== 'string') {
|
|
348
|
+
throw new Error('Blueprint name is required');
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (blueprintName.includes('..') || blueprintName.includes('/') || blueprintName.includes('\\')) {
|
|
352
|
+
throw new Error('Invalid blueprint name');
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (!availableBlueprints.includes(blueprintName)) {
|
|
356
|
+
const hint = availableBlueprints.length > 0 ? ` Available: ${availableBlueprints.join(', ')}` : '';
|
|
357
|
+
throw new Error(`Blueprint "${blueprintName}" não encontrado.${hint}`);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const blueprintDir = path.join(blueprintsRoot, blueprintName);
|
|
321
361
|
|
|
322
362
|
if (!existsSync(blueprintDir)) {
|
|
323
363
|
throw new Error(`Blueprint "${blueprintName}" não encontrado`);
|
|
@@ -335,7 +375,7 @@ async function copyBlueprint(blueprintName, targetDir, replacements) {
|
|
|
335
375
|
program
|
|
336
376
|
.name('openkit')
|
|
337
377
|
.description('OpenKit - OpenCode Agent System in your project')
|
|
338
|
-
.version('0.
|
|
378
|
+
.version('0.2.0');
|
|
339
379
|
|
|
340
380
|
program
|
|
341
381
|
.command('init')
|
|
@@ -404,6 +444,8 @@ program
|
|
|
404
444
|
const templateConfigPath = path.join(__dirname, '..', 'opencode.json');
|
|
405
445
|
const targetConfigPath = path.join(projectDir, 'opencode.json');
|
|
406
446
|
|
|
447
|
+
const willWriteRootConfig = existsSync(templateConfigPath) && (!existsSync(targetConfigPath) || options.force);
|
|
448
|
+
|
|
407
449
|
if (existsSync(templateConfigPath)) {
|
|
408
450
|
if (existsSync(targetConfigPath) && !options.force) {
|
|
409
451
|
console.log(chalk.yellow('Warning: opencode.json already exists, skipping'));
|
|
@@ -413,6 +455,20 @@ program
|
|
|
413
455
|
}
|
|
414
456
|
}
|
|
415
457
|
|
|
458
|
+
// Write install manifest for safe upgrades (no extra console noise).
|
|
459
|
+
try {
|
|
460
|
+
await writeInitManifest({
|
|
461
|
+
fs,
|
|
462
|
+
projectRootAbs: projectDir,
|
|
463
|
+
templateDirAbs: templateDir,
|
|
464
|
+
templateRootConfigAbs: templateConfigPath,
|
|
465
|
+
openkitVersion: getOpenkitVersion(),
|
|
466
|
+
includeRootConfig: willWriteRootConfig
|
|
467
|
+
});
|
|
468
|
+
} catch (e) {
|
|
469
|
+
console.log(chalk.yellow(`Warning: failed to write manifest (${e.message})`));
|
|
470
|
+
}
|
|
471
|
+
|
|
416
472
|
if (options.blueprint) {
|
|
417
473
|
const replacements = {
|
|
418
474
|
'{{PROJECT_NAME}}': metadata.projectName,
|
|
@@ -441,6 +497,99 @@ program
|
|
|
441
497
|
console.log('');
|
|
442
498
|
});
|
|
443
499
|
|
|
500
|
+
program
|
|
501
|
+
.command('upgrade')
|
|
502
|
+
.description('Upgrade OpenCode configuration in current directory')
|
|
503
|
+
.option('--dry-run', 'Plan changes without writing')
|
|
504
|
+
.option('--yes', 'Assume defaults (TTY only); conflicts default to skip')
|
|
505
|
+
.option('--force', 'Overwrite all managed files (with backup)')
|
|
506
|
+
.option('--overwrite-changed', 'Overwrite customized files (with backup)')
|
|
507
|
+
.option('--fail-on-changes', 'Exit with code 2 if conflicts/customizations detected')
|
|
508
|
+
.option('--prune', 'Remove files no longer present in template (with backup)')
|
|
509
|
+
.option('--manifest-path <path>', 'Override manifest path (relative to project root)')
|
|
510
|
+
.action(async (options) => {
|
|
511
|
+
const projectDir = process.cwd();
|
|
512
|
+
const opencodeDir = path.join(projectDir, '.opencode');
|
|
513
|
+
|
|
514
|
+
try {
|
|
515
|
+
const st = await fs.lstat(opencodeDir);
|
|
516
|
+
if (st.isSymbolicLink()) throw new Error('.opencode is a symlink');
|
|
517
|
+
if (!st.isDirectory()) throw new Error('.opencode is not a directory');
|
|
518
|
+
} catch (e) {
|
|
519
|
+
if (e && (e.code === 'ENOENT' || e.code === 'ENOTDIR')) {
|
|
520
|
+
console.log('Missing .opencode directory. Run `openkit init` first.');
|
|
521
|
+
process.exit(1);
|
|
522
|
+
}
|
|
523
|
+
console.log(`Upgrade failed: ${e.message}`);
|
|
524
|
+
process.exit(1);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
528
|
+
const templateDir = path.join(__dirname, '..', '.opencode');
|
|
529
|
+
const templateRootConfig = path.join(__dirname, '..', 'opencode.json');
|
|
530
|
+
const hasTemplateRootConfig = existsSync(templateRootConfig);
|
|
531
|
+
|
|
532
|
+
try {
|
|
533
|
+
const result = await runUpgrade({
|
|
534
|
+
fs,
|
|
535
|
+
prompt: inquirer.prompt,
|
|
536
|
+
projectRootAbs: projectDir,
|
|
537
|
+
templateDirAbs: templateDir,
|
|
538
|
+
templateRootConfigAbs: hasTemplateRootConfig ? templateRootConfig : null,
|
|
539
|
+
openkitVersion: getOpenkitVersion(),
|
|
540
|
+
options: {
|
|
541
|
+
dryRun: Boolean(options.dryRun),
|
|
542
|
+
yes: Boolean(options.yes),
|
|
543
|
+
force: Boolean(options.force),
|
|
544
|
+
overwriteChanged: Boolean(options.overwriteChanged),
|
|
545
|
+
failOnChanges: Boolean(options.failOnChanges),
|
|
546
|
+
prune: Boolean(options.prune) || Boolean(options.force),
|
|
547
|
+
manifestPath: options.manifestPath,
|
|
548
|
+
isInteractive
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
const { buckets, backupsDir } = result;
|
|
553
|
+
const counts = {
|
|
554
|
+
added: buckets.add.length,
|
|
555
|
+
updated: buckets.update.length,
|
|
556
|
+
overwritten: buckets.overwrite.length,
|
|
557
|
+
removed: buckets.remove.length,
|
|
558
|
+
skipped: buckets.skip.length,
|
|
559
|
+
conflicts: buckets.conflicts.length,
|
|
560
|
+
orphaned: buckets.orphaned.length
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
const lines = [];
|
|
564
|
+
lines.push(`added: ${counts.added}`);
|
|
565
|
+
lines.push(`updated: ${counts.updated}`);
|
|
566
|
+
lines.push(`overwritten: ${counts.overwritten}`);
|
|
567
|
+
lines.push(`removed: ${counts.removed}`);
|
|
568
|
+
lines.push(`skipped: ${counts.skipped}`);
|
|
569
|
+
lines.push(`conflicts: ${counts.conflicts}`);
|
|
570
|
+
lines.push(`orphaned: ${counts.orphaned}`);
|
|
571
|
+
if (backupsDir) lines.push(`backups: ${backupsDir}`);
|
|
572
|
+
console.log(lines.join('\n'));
|
|
573
|
+
|
|
574
|
+
const shouldListAll = Boolean(options.dryRun);
|
|
575
|
+
const listBuckets = shouldListAll
|
|
576
|
+
? ['add', 'update', 'overwrite', 'remove', 'skip', 'conflicts', 'orphaned']
|
|
577
|
+
: ['skip', 'conflicts', 'orphaned', 'remove'];
|
|
578
|
+
|
|
579
|
+
for (const key of listBuckets) {
|
|
580
|
+
const arr = buckets[key];
|
|
581
|
+
if (!arr || arr.length === 0) continue;
|
|
582
|
+
console.log(`\n${key}:`);
|
|
583
|
+
for (const p of arr.sort()) console.log(` ${p}`);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
process.exit(result.exitCode);
|
|
587
|
+
} catch (e) {
|
|
588
|
+
console.log(`Upgrade failed: ${e.message}`);
|
|
589
|
+
process.exit(1);
|
|
590
|
+
}
|
|
591
|
+
});
|
|
592
|
+
|
|
444
593
|
program
|
|
445
594
|
.command('doctor')
|
|
446
595
|
.description('Check OpenCode installation')
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import { resolveWithinRoot, assertNoSymlinkInPath } from './paths.js';
|
|
3
|
+
import { sha256Hex } from './sha256.js';
|
|
4
|
+
|
|
5
|
+
async function existsNoFollow(fs, absPath) {
|
|
6
|
+
try {
|
|
7
|
+
const st = await fs.lstat(absPath);
|
|
8
|
+
return { exists: true, stat: st };
|
|
9
|
+
} catch (e) {
|
|
10
|
+
if (e && (e.code === 'ENOENT' || e.code === 'ENOTDIR')) return { exists: false, stat: null };
|
|
11
|
+
throw e;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function ensureParentDir(fs, absPath) {
|
|
16
|
+
await fs.mkdir(path.dirname(absPath), { recursive: true });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function writeFileAtomic(fs, absPath, content) {
|
|
20
|
+
const tmp = `${absPath}.tmp-${sha256Hex(String(Date.now())).slice(0, 8)}`;
|
|
21
|
+
await fs.writeFile(tmp, content);
|
|
22
|
+
await fs.rename(tmp, absPath);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function backupFile({ fs, projectRootAbs, backupsDirAbs, relPath }) {
|
|
26
|
+
const srcAbs = resolveWithinRoot(projectRootAbs, relPath);
|
|
27
|
+
const dstAbs = path.join(backupsDirAbs, relPath.replace(/\\/g, '/'));
|
|
28
|
+
await fs.mkdir(path.dirname(dstAbs), { recursive: true });
|
|
29
|
+
await fs.copyFile(srcAbs, dstAbs);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function applyUpgradeOps({ fs, projectRootAbs, templateFiles, ops, backupsDirRel }) {
|
|
33
|
+
const modified = [];
|
|
34
|
+
const removed = [];
|
|
35
|
+
const added = [];
|
|
36
|
+
|
|
37
|
+
const willOverwriteOrRemove = ops.some((op) => op.type === 'update' || op.type === 'overwrite' || op.type === 'remove');
|
|
38
|
+
const backupsDirAbs = willOverwriteOrRemove ? resolveWithinRoot(projectRootAbs, backupsDirRel) : null;
|
|
39
|
+
if (backupsDirAbs) {
|
|
40
|
+
await assertNoSymlinkInPath(fs, projectRootAbs, backupsDirRel);
|
|
41
|
+
await fs.mkdir(backupsDirAbs, { recursive: true });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
for (const op of ops) {
|
|
45
|
+
if (op.type === 'orphaned' || op.type === 'conflict' || op.type === 'skip') continue;
|
|
46
|
+
|
|
47
|
+
await assertNoSymlinkInPath(fs, projectRootAbs, op.path);
|
|
48
|
+
const abs = resolveWithinRoot(projectRootAbs, op.path);
|
|
49
|
+
|
|
50
|
+
const { exists, stat } = await existsNoFollow(fs, abs);
|
|
51
|
+
if (exists && stat.isSymbolicLink()) throw new Error(`Refusing to operate on symlink: ${op.path}`);
|
|
52
|
+
if (exists && !stat.isFile()) throw new Error(`Refusing to operate on non-file path: ${op.path}`);
|
|
53
|
+
|
|
54
|
+
if (op.type === 'remove') {
|
|
55
|
+
if (exists) {
|
|
56
|
+
await backupFile({ fs, projectRootAbs, backupsDirAbs, relPath: op.path });
|
|
57
|
+
await fs.unlink(abs);
|
|
58
|
+
removed.push(op.path);
|
|
59
|
+
}
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const tf = templateFiles.get(op.path);
|
|
64
|
+
if (!tf) throw new Error(`Missing template content for: ${op.path}`);
|
|
65
|
+
|
|
66
|
+
if (op.type === 'add') {
|
|
67
|
+
await ensureParentDir(fs, abs);
|
|
68
|
+
await writeFileAtomic(fs, abs, tf.content);
|
|
69
|
+
added.push(op.path);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (op.type === 'update' || op.type === 'overwrite') {
|
|
74
|
+
if (exists) await backupFile({ fs, projectRootAbs, backupsDirAbs, relPath: op.path });
|
|
75
|
+
await ensureParentDir(fs, abs);
|
|
76
|
+
await writeFileAtomic(fs, abs, tf.content);
|
|
77
|
+
modified.push(op.path);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
backupsDir: backupsDirAbs ? backupsDirRel : null,
|
|
83
|
+
added,
|
|
84
|
+
modified,
|
|
85
|
+
removed
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import { sha256Hex } from './sha256.js';
|
|
3
|
+
import { resolveWithinRoot, assertNoSymlinkInPath } from './paths.js';
|
|
4
|
+
import { readTemplateFiles } from './template.js';
|
|
5
|
+
import { loadManifestIfExists, writeManifest, createEmptyManifest, defaultDecision, upsertManifestFile, indexManifestFiles } from './manifest.js';
|
|
6
|
+
import { planUpgrade } from './planner.js';
|
|
7
|
+
import { applyUpgradeOps } from './applier.js';
|
|
8
|
+
|
|
9
|
+
async function readProjectFileState({ fs, projectRootAbs, relPath }) {
|
|
10
|
+
await assertNoSymlinkInPath(fs, projectRootAbs, relPath);
|
|
11
|
+
const abs = resolveWithinRoot(projectRootAbs, relPath);
|
|
12
|
+
try {
|
|
13
|
+
const st = await fs.lstat(abs);
|
|
14
|
+
if (st.isSymbolicLink()) throw new Error(`Refusing to read through symlink: ${relPath}`);
|
|
15
|
+
if (!st.isFile()) throw new Error(`Expected file but found non-file: ${relPath}`);
|
|
16
|
+
const buf = await fs.readFile(abs);
|
|
17
|
+
return { exists: true, currentSha256: sha256Hex(buf) };
|
|
18
|
+
} catch (e) {
|
|
19
|
+
if (e && (e.code === 'ENOENT' || e.code === 'ENOTDIR')) return { exists: false, currentSha256: null };
|
|
20
|
+
throw e;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function projectHasRegularFileNoFollow({ fs, projectRootAbs, relPath }) {
|
|
25
|
+
await assertNoSymlinkInPath(fs, projectRootAbs, relPath);
|
|
26
|
+
const abs = resolveWithinRoot(projectRootAbs, relPath);
|
|
27
|
+
try {
|
|
28
|
+
const st = await fs.lstat(abs);
|
|
29
|
+
if (st.isSymbolicLink()) return false;
|
|
30
|
+
return st.isFile();
|
|
31
|
+
} catch (e) {
|
|
32
|
+
if (e && (e.code === 'ENOENT' || e.code === 'ENOTDIR')) return false;
|
|
33
|
+
throw e;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function nowIso() {
|
|
38
|
+
return new Date().toISOString();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function backupsDirRelFromNow() {
|
|
42
|
+
const safe = nowIso().replace(/[:.]/g, '-');
|
|
43
|
+
return `.opencode/.openkit-backups/openkit-upgrade-${safe}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function summarizeOps(ops) {
|
|
47
|
+
const buckets = {
|
|
48
|
+
add: [],
|
|
49
|
+
update: [],
|
|
50
|
+
overwrite: [],
|
|
51
|
+
remove: [],
|
|
52
|
+
skip: [],
|
|
53
|
+
conflict: [],
|
|
54
|
+
orphaned: []
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
for (const op of ops) {
|
|
58
|
+
if (buckets[op.type]) buckets[op.type].push(op.path);
|
|
59
|
+
else if (op.type === 'conflict') buckets.conflict.push(op.path);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return buckets;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function runUpgrade({
|
|
66
|
+
fs,
|
|
67
|
+
prompt,
|
|
68
|
+
projectRootAbs,
|
|
69
|
+
templateDirAbs,
|
|
70
|
+
templateRootConfigAbs,
|
|
71
|
+
openkitVersion,
|
|
72
|
+
options
|
|
73
|
+
}) {
|
|
74
|
+
const manifestRelPath = options.manifestPath || '.opencode/openkit.manifest.json';
|
|
75
|
+
const existingManifest = await loadManifestIfExists({ fs, projectRootAbs, manifestRelPath });
|
|
76
|
+
|
|
77
|
+
const rootConfigExists = await projectHasRegularFileNoFollow({ fs, projectRootAbs, relPath: 'opencode.json' });
|
|
78
|
+
|
|
79
|
+
const manageRootConfig = Boolean(
|
|
80
|
+
templateRootConfigAbs &&
|
|
81
|
+
(options.force || !rootConfigExists || (existingManifest && existingManifest.managedPaths.includes('opencode.json')))
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const templateFiles = await readTemplateFiles({
|
|
85
|
+
fs,
|
|
86
|
+
templateDirAbs,
|
|
87
|
+
includeRootConfig: manageRootConfig,
|
|
88
|
+
rootConfigPathAbs: templateRootConfigAbs
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const projectFileStates = new Map();
|
|
92
|
+
for (const relPath of templateFiles.keys()) {
|
|
93
|
+
projectFileStates.set(relPath, await readProjectFileState({ fs, projectRootAbs, relPath }));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const ops = planUpgrade({
|
|
97
|
+
templateFiles,
|
|
98
|
+
manifest: existingManifest,
|
|
99
|
+
projectFileStates,
|
|
100
|
+
options: {
|
|
101
|
+
...options,
|
|
102
|
+
isInteractive: Boolean(options.isInteractive)
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Resolve interactive conflicts (only those still marked as 'conflict').
|
|
107
|
+
if (options.isInteractive && !options.yes) {
|
|
108
|
+
for (const op of ops) {
|
|
109
|
+
if (op.type !== 'conflict') continue;
|
|
110
|
+
|
|
111
|
+
const question = {
|
|
112
|
+
type: 'confirm',
|
|
113
|
+
name: 'overwrite',
|
|
114
|
+
message: `Arquivo modificado: ${op.path}. Sobrescrever com o template?`,
|
|
115
|
+
default: false
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const rememberQ = {
|
|
119
|
+
type: 'confirm',
|
|
120
|
+
name: 'remember',
|
|
121
|
+
message: 'Lembrar esta decisao para proximos upgrades?',
|
|
122
|
+
default: true
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const ans = await prompt([question, rememberQ]);
|
|
126
|
+
op.type = ans.overwrite ? 'overwrite' : 'skip';
|
|
127
|
+
op.reason = op.reason || 'conflict';
|
|
128
|
+
op._remember = Boolean(ans.remember);
|
|
129
|
+
op._rememberChoice = ans.overwrite ? 'overwrite' : 'skip';
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const buckets = summarizeOps(ops);
|
|
134
|
+
const conflicts = ops
|
|
135
|
+
.filter((op) => op && (op.reason === 'conflict' || op.reason === 'legacy-conflict'))
|
|
136
|
+
.map((op) => op.path);
|
|
137
|
+
|
|
138
|
+
const exitCode = options.failOnChanges && conflicts.length > 0 ? 2 : 0;
|
|
139
|
+
|
|
140
|
+
if (options.dryRun) {
|
|
141
|
+
return {
|
|
142
|
+
didWrite: false,
|
|
143
|
+
exitCode,
|
|
144
|
+
buckets: { ...buckets, conflicts },
|
|
145
|
+
backupsDir: null
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const applyResult = await applyUpgradeOps({
|
|
150
|
+
fs,
|
|
151
|
+
projectRootAbs,
|
|
152
|
+
templateFiles,
|
|
153
|
+
ops,
|
|
154
|
+
backupsDirRel: backupsDirRelFromNow()
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// Manifest update (best-effort baseline tracking).
|
|
158
|
+
const installedAt = existingManifest ? existingManifest.installedAt : nowIso();
|
|
159
|
+
const manifest = existingManifest || createEmptyManifest({
|
|
160
|
+
openkitVersion,
|
|
161
|
+
installedAtIso: installedAt,
|
|
162
|
+
managedPaths: manageRootConfig ? ['.opencode', 'opencode.json'] : ['.opencode']
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
manifest.openkitVersion = openkitVersion;
|
|
166
|
+
if (!manifest.managedPaths.includes('.opencode')) manifest.managedPaths.unshift('.opencode');
|
|
167
|
+
if (manageRootConfig && !manifest.managedPaths.includes('opencode.json')) manifest.managedPaths.push('opencode.json');
|
|
168
|
+
|
|
169
|
+
const mfIndex = indexManifestFiles(manifest);
|
|
170
|
+
for (const [relPath, tf] of templateFiles.entries()) {
|
|
171
|
+
const existing = mfIndex.get(relPath);
|
|
172
|
+
const op = ops.find((o) => o.path === relPath);
|
|
173
|
+
const applied = op && (op.type === 'add' || op.type === 'update' || op.type === 'overwrite');
|
|
174
|
+
|
|
175
|
+
const next = existing ? { ...existing } : {
|
|
176
|
+
path: relPath,
|
|
177
|
+
kind: tf.kind,
|
|
178
|
+
baseSha256: tf.templateSha256,
|
|
179
|
+
lastAppliedOpenkitVersion: openkitVersion,
|
|
180
|
+
decision: defaultDecision()
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
if (applied) {
|
|
184
|
+
next.baseSha256 = tf.templateSha256;
|
|
185
|
+
next.lastAppliedOpenkitVersion = openkitVersion;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (op && op._remember && next.decision && next.decision.remember) {
|
|
189
|
+
next.decision.onConflict = op._rememberChoice;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
upsertManifestFile(manifest, next);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Orphaned cleanup in manifest only if removed.
|
|
196
|
+
if (applyResult.removed.length > 0) {
|
|
197
|
+
const removedSet = new Set(applyResult.removed);
|
|
198
|
+
manifest.files = manifest.files.filter((f) => !(f && removedSet.has(f.path)));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
manifest.history.push({
|
|
202
|
+
type: 'upgrade',
|
|
203
|
+
openkitVersion,
|
|
204
|
+
timestamp: nowIso()
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
await writeManifest({ fs, projectRootAbs, manifestRelPath, manifest });
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
didWrite: true,
|
|
211
|
+
exitCode,
|
|
212
|
+
buckets: { ...buckets, conflicts },
|
|
213
|
+
backupsDir: applyResult.backupsDir
|
|
214
|
+
};
|
|
215
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { readTemplateFiles } from './template.js';
|
|
2
|
+
import { createEmptyManifest, defaultDecision, upsertManifestFile, writeManifest } from './manifest.js';
|
|
3
|
+
|
|
4
|
+
function nowIso() {
|
|
5
|
+
return new Date().toISOString();
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function writeInitManifest({
|
|
9
|
+
fs,
|
|
10
|
+
projectRootAbs,
|
|
11
|
+
templateDirAbs,
|
|
12
|
+
templateRootConfigAbs,
|
|
13
|
+
openkitVersion,
|
|
14
|
+
includeRootConfig,
|
|
15
|
+
manifestRelPath = '.opencode/openkit.manifest.json'
|
|
16
|
+
}) {
|
|
17
|
+
const templateFiles = await readTemplateFiles({
|
|
18
|
+
fs,
|
|
19
|
+
templateDirAbs,
|
|
20
|
+
includeRootConfig,
|
|
21
|
+
rootConfigPathAbs: templateRootConfigAbs
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const manifest = createEmptyManifest({
|
|
25
|
+
openkitVersion,
|
|
26
|
+
installedAtIso: nowIso(),
|
|
27
|
+
managedPaths: includeRootConfig ? ['.opencode', 'opencode.json'] : ['.opencode']
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
for (const [relPath, tf] of templateFiles.entries()) {
|
|
31
|
+
upsertManifestFile(manifest, {
|
|
32
|
+
path: relPath,
|
|
33
|
+
kind: tf.kind,
|
|
34
|
+
baseSha256: tf.templateSha256,
|
|
35
|
+
lastAppliedOpenkitVersion: openkitVersion,
|
|
36
|
+
decision: defaultDecision()
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
manifest.history.push({
|
|
41
|
+
type: 'init',
|
|
42
|
+
openkitVersion,
|
|
43
|
+
timestamp: nowIso()
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
await writeManifest({ fs, projectRootAbs, manifestRelPath, manifest });
|
|
47
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { sha256Hex } from './sha256.js';
|
|
2
|
+
import { resolveWithinRoot, assertNoSymlinkInPath } from './paths.js';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
export function createEmptyManifest({ openkitVersion, installedAtIso, managedPaths }) {
|
|
6
|
+
return {
|
|
7
|
+
schemaVersion: 1,
|
|
8
|
+
managedBy: '@paulojalowyj/openkit',
|
|
9
|
+
openkitVersion,
|
|
10
|
+
installedAt: installedAtIso,
|
|
11
|
+
projectRoot: '.',
|
|
12
|
+
managedPaths,
|
|
13
|
+
files: [],
|
|
14
|
+
history: []
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function validateManifest(manifest) {
|
|
19
|
+
if (!manifest || typeof manifest !== 'object') throw new Error('Invalid manifest: not an object');
|
|
20
|
+
if (manifest.schemaVersion !== 1) throw new Error(`Unsupported manifest schemaVersion: ${manifest.schemaVersion}`);
|
|
21
|
+
if (manifest.managedBy !== '@paulojalowyj/openkit') throw new Error('Invalid manifest: managedBy mismatch');
|
|
22
|
+
if (!Array.isArray(manifest.files)) throw new Error('Invalid manifest: files must be an array');
|
|
23
|
+
if (!Array.isArray(manifest.history)) throw new Error('Invalid manifest: history must be an array');
|
|
24
|
+
if (!Array.isArray(manifest.managedPaths)) throw new Error('Invalid manifest: managedPaths must be an array');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function loadManifestIfExists({ fs, projectRootAbs, manifestRelPath }) {
|
|
28
|
+
await assertNoSymlinkInPath(fs, projectRootAbs, manifestRelPath);
|
|
29
|
+
const abs = resolveWithinRoot(projectRootAbs, manifestRelPath);
|
|
30
|
+
try {
|
|
31
|
+
const st = await fs.lstat(abs);
|
|
32
|
+
if (st.isSymbolicLink()) throw new Error('Refusing to read manifest through symlink');
|
|
33
|
+
if (!st.isFile()) throw new Error('Manifest path exists but is not a file');
|
|
34
|
+
} catch (e) {
|
|
35
|
+
if (e && (e.code === 'ENOENT' || e.code === 'ENOTDIR')) return null;
|
|
36
|
+
throw e;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const raw = await fs.readFile(abs, 'utf8');
|
|
40
|
+
const parsed = JSON.parse(raw);
|
|
41
|
+
validateManifest(parsed);
|
|
42
|
+
return parsed;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function writeManifest({ fs, projectRootAbs, manifestRelPath, manifest }) {
|
|
46
|
+
validateManifest(manifest);
|
|
47
|
+
|
|
48
|
+
const abs = resolveWithinRoot(projectRootAbs, manifestRelPath);
|
|
49
|
+
await assertNoSymlinkInPath(fs, projectRootAbs, manifestRelPath);
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const st = await fs.lstat(abs);
|
|
53
|
+
if (st.isSymbolicLink()) throw new Error('Refusing to write manifest to symlink path');
|
|
54
|
+
} catch (e) {
|
|
55
|
+
if (!e || (e.code !== 'ENOENT' && e.code !== 'ENOTDIR')) throw e;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
59
|
+
|
|
60
|
+
const tmp = `${abs}.tmp-${sha256Hex(String(Date.now())).slice(0, 8)}`;
|
|
61
|
+
const json = JSON.stringify(manifest, null, 2) + '\n';
|
|
62
|
+
await fs.writeFile(tmp, json, 'utf8');
|
|
63
|
+
await fs.rename(tmp, abs);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function indexManifestFiles(manifest) {
|
|
67
|
+
const byPath = new Map();
|
|
68
|
+
for (const f of manifest.files || []) {
|
|
69
|
+
if (f && typeof f.path === 'string') byPath.set(f.path, f);
|
|
70
|
+
}
|
|
71
|
+
return byPath;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function upsertManifestFile(manifest, fileEntry) {
|
|
75
|
+
const idx = (manifest.files || []).findIndex((f) => f && f.path === fileEntry.path);
|
|
76
|
+
if (idx === -1) manifest.files.push(fileEntry);
|
|
77
|
+
else manifest.files[idx] = fileEntry;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function defaultDecision() {
|
|
81
|
+
return {
|
|
82
|
+
strategy: 'follow-template',
|
|
83
|
+
onConflict: 'prompt',
|
|
84
|
+
remember: true
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
|
|
3
|
+
function toPosixPath(p) {
|
|
4
|
+
return String(p).replace(/\\/g, '/');
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function normalizeRelPath(inputPath) {
|
|
8
|
+
const p = toPosixPath(inputPath);
|
|
9
|
+
if (p.includes('\u0000')) throw new Error('Invalid path: contains null byte');
|
|
10
|
+
if (path.posix.isAbsolute(p)) throw new Error(`Invalid path: absolute path not allowed (${inputPath})`);
|
|
11
|
+
|
|
12
|
+
const normalized = path.posix.normalize(p);
|
|
13
|
+
if (normalized === '.' || normalized === '') return '';
|
|
14
|
+
if (normalized.startsWith('../') || normalized === '..') {
|
|
15
|
+
throw new Error(`Invalid path: path traversal not allowed (${inputPath})`);
|
|
16
|
+
}
|
|
17
|
+
return normalized;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function resolveWithinRoot(projectRootAbs, relPath) {
|
|
21
|
+
const root = path.resolve(projectRootAbs);
|
|
22
|
+
const rel = normalizeRelPath(relPath);
|
|
23
|
+
const abs = path.resolve(root, rel);
|
|
24
|
+
const rootWithSep = root.endsWith(path.sep) ? root : root + path.sep;
|
|
25
|
+
if (abs !== root && !abs.startsWith(rootWithSep)) {
|
|
26
|
+
throw new Error(`Path escapes project root: ${relPath}`);
|
|
27
|
+
}
|
|
28
|
+
return abs;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function assertNoSymlinkInPath(fs, projectRootAbs, relPath) {
|
|
32
|
+
const root = path.resolve(projectRootAbs);
|
|
33
|
+
const rel = normalizeRelPath(relPath);
|
|
34
|
+
if (!rel) return;
|
|
35
|
+
|
|
36
|
+
const parts = rel.split('/');
|
|
37
|
+
let cursor = root;
|
|
38
|
+
|
|
39
|
+
for (const part of parts.slice(0, -1)) {
|
|
40
|
+
cursor = path.join(cursor, part);
|
|
41
|
+
let st;
|
|
42
|
+
try {
|
|
43
|
+
st = await fs.lstat(cursor);
|
|
44
|
+
} catch {
|
|
45
|
+
// Missing directories will be created later; we only guard existing ones.
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (st.isSymbolicLink()) {
|
|
49
|
+
throw new Error(`Refusing to traverse symlinked directory: ${path.relative(root, cursor)}`);
|
|
50
|
+
}
|
|
51
|
+
if (!st.isDirectory()) {
|
|
52
|
+
throw new Error(`Expected directory but found file: ${path.relative(root, cursor)}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { indexManifestFiles, defaultDecision } from './manifest.js';
|
|
2
|
+
|
|
3
|
+
export function planUpgrade({ templateFiles, manifest, projectFileStates, options }) {
|
|
4
|
+
const manifestFiles = manifest ? indexManifestFiles(manifest) : new Map();
|
|
5
|
+
const templatePaths = new Set([...templateFiles.keys()]);
|
|
6
|
+
|
|
7
|
+
const ops = [];
|
|
8
|
+
|
|
9
|
+
for (const [relPath, tf] of templateFiles.entries()) {
|
|
10
|
+
const state = projectFileStates.get(relPath) || { exists: false };
|
|
11
|
+
const mf = manifestFiles.get(relPath);
|
|
12
|
+
const decision = (mf && mf.decision) ? mf.decision : defaultDecision();
|
|
13
|
+
|
|
14
|
+
if (!state.exists) {
|
|
15
|
+
ops.push({
|
|
16
|
+
type: 'add',
|
|
17
|
+
path: relPath,
|
|
18
|
+
kind: tf.kind,
|
|
19
|
+
templateSha256: tf.templateSha256
|
|
20
|
+
});
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (options.force) {
|
|
25
|
+
ops.push({ type: 'overwrite', path: relPath, kind: tf.kind, templateSha256: tf.templateSha256, reason: 'force' });
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (mf && typeof mf.baseSha256 === 'string' && state.currentSha256 === mf.baseSha256) {
|
|
30
|
+
ops.push({ type: 'update', path: relPath, kind: tf.kind, templateSha256: tf.templateSha256, reason: 'unchanged' });
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const conflictKind = mf ? 'conflict' : 'legacy-conflict';
|
|
35
|
+
let resolution = null;
|
|
36
|
+
|
|
37
|
+
if (options.overwriteChanged) resolution = 'overwrite';
|
|
38
|
+
else if (!options.isInteractive || options.yes) resolution = 'skip';
|
|
39
|
+
else if (decision && decision.remember) {
|
|
40
|
+
if (decision.onConflict === 'overwrite') resolution = 'overwrite';
|
|
41
|
+
if (decision.onConflict === 'skip') resolution = 'skip';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
ops.push({
|
|
45
|
+
type: resolution === 'overwrite' ? 'overwrite' : resolution === 'skip' ? 'skip' : 'conflict',
|
|
46
|
+
path: relPath,
|
|
47
|
+
kind: tf.kind,
|
|
48
|
+
templateSha256: tf.templateSha256,
|
|
49
|
+
reason: conflictKind
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const orphaned = [];
|
|
54
|
+
if (manifest) {
|
|
55
|
+
for (const f of manifest.files) {
|
|
56
|
+
if (!f || typeof f.path !== 'string') continue;
|
|
57
|
+
if (!templatePaths.has(f.path)) orphaned.push(f.path);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (const p of orphaned) {
|
|
62
|
+
if (options.force || options.prune) ops.push({ type: 'remove', path: p, reason: 'orphaned' });
|
|
63
|
+
else ops.push({ type: 'orphaned', path: p, reason: 'orphaned' });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return ops;
|
|
67
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import { sha256Hex } from './sha256.js';
|
|
3
|
+
|
|
4
|
+
async function listFilesRecursive(fs, dirAbs, relBase = '') {
|
|
5
|
+
const entries = await fs.readdir(dirAbs, { withFileTypes: true });
|
|
6
|
+
const out = [];
|
|
7
|
+
|
|
8
|
+
for (const ent of entries) {
|
|
9
|
+
const rel = relBase ? `${relBase}/${ent.name}` : ent.name;
|
|
10
|
+
const abs = path.join(dirAbs, ent.name);
|
|
11
|
+
|
|
12
|
+
if (ent.isSymbolicLink()) {
|
|
13
|
+
throw new Error(`Template contains symlink (unsupported): ${rel}`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (ent.isDirectory()) {
|
|
17
|
+
out.push(...await listFilesRecursive(fs, abs, rel));
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (ent.isFile()) {
|
|
22
|
+
out.push({ rel, abs });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function readTemplateFiles({ fs, templateDirAbs, includeRootConfig, rootConfigPathAbs }) {
|
|
30
|
+
const files = new Map();
|
|
31
|
+
|
|
32
|
+
const templateEntries = await listFilesRecursive(fs, templateDirAbs);
|
|
33
|
+
for (const { rel, abs } of templateEntries) {
|
|
34
|
+
const content = await fs.readFile(abs);
|
|
35
|
+
const relPath = `.opencode/${rel.replace(/\\/g, '/')}`;
|
|
36
|
+
files.set(relPath, {
|
|
37
|
+
path: relPath,
|
|
38
|
+
kind: 'template',
|
|
39
|
+
content,
|
|
40
|
+
templateSha256: sha256Hex(content)
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (includeRootConfig) {
|
|
45
|
+
const content = await fs.readFile(rootConfigPathAbs);
|
|
46
|
+
const relPath = 'opencode.json';
|
|
47
|
+
files.set(relPath, {
|
|
48
|
+
path: relPath,
|
|
49
|
+
kind: 'root-config',
|
|
50
|
+
content,
|
|
51
|
+
templateSha256: sha256Hex(content)
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return files;
|
|
56
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import { promises as fs } from 'node:fs';
|
|
4
|
+
|
|
5
|
+
function toPosix(p) {
|
|
6
|
+
return p.replaceAll('\\\\', '/');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function assertSafeRelativePath(rel) {
|
|
10
|
+
const normalized = toPosix(rel);
|
|
11
|
+
if (path.isAbsolute(normalized)) {
|
|
12
|
+
throw new Error(`Path must be relative: ${rel}`);
|
|
13
|
+
}
|
|
14
|
+
const parts = normalized.split('/');
|
|
15
|
+
if (parts.includes('..')) {
|
|
16
|
+
throw new Error(`Path traversal is not allowed: ${rel}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function sha256Hex(content) {
|
|
21
|
+
const buf = Buffer.isBuffer(content) ? content : Buffer.from(String(content));
|
|
22
|
+
return crypto.createHash('sha256').update(buf).digest('hex');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function listFilesRecursive(rootDir) {
|
|
26
|
+
const out = [];
|
|
27
|
+
|
|
28
|
+
async function walk(currentDir) {
|
|
29
|
+
const entries = await fs.readdir(currentDir, { withFileTypes: true });
|
|
30
|
+
for (const entry of entries) {
|
|
31
|
+
if (entry.isSymbolicLink()) continue;
|
|
32
|
+
const abs = path.join(currentDir, entry.name);
|
|
33
|
+
if (entry.isDirectory()) {
|
|
34
|
+
await walk(abs);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
out.push(abs);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
await walk(rootDir);
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function readManifest(manifestPath) {
|
|
46
|
+
try {
|
|
47
|
+
const raw = await fs.readFile(manifestPath, 'utf8');
|
|
48
|
+
return JSON.parse(raw);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
if (err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')) return null;
|
|
51
|
+
throw err;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function writeManifest(manifestPath, manifest) {
|
|
56
|
+
await fs.mkdir(path.dirname(manifestPath), { recursive: true });
|
|
57
|
+
const json = JSON.stringify(manifest, null, 2) + '\n';
|
|
58
|
+
await fs.writeFile(manifestPath, json, 'utf8');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function manifestBaseShaByPath(manifest) {
|
|
62
|
+
const map = new Map();
|
|
63
|
+
if (!manifest) return map;
|
|
64
|
+
|
|
65
|
+
const files = manifest.files;
|
|
66
|
+
if (Array.isArray(files)) {
|
|
67
|
+
for (const f of files) {
|
|
68
|
+
if (!f || typeof f.path !== 'string') continue;
|
|
69
|
+
if (typeof f.baseSha256 === 'string' && f.baseSha256) {
|
|
70
|
+
map.set(toPosix(f.path), f.baseSha256);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return map;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (files && typeof files === 'object') {
|
|
77
|
+
for (const [p, meta] of Object.entries(files)) {
|
|
78
|
+
if (meta && typeof meta.baseSha256 === 'string' && meta.baseSha256) {
|
|
79
|
+
map.set(toPosix(p), meta.baseSha256);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return map;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function upsertManifestFileEntry(filesArr, filePath, baseSha256, openkitVersion) {
|
|
88
|
+
const p = toPosix(filePath);
|
|
89
|
+
let existing = filesArr.find((f) => f && f.path === p);
|
|
90
|
+
if (!existing) {
|
|
91
|
+
existing = { path: p, kind: p.startsWith('.opencode/') ? 'template' : 'root-config' };
|
|
92
|
+
filesArr.push(existing);
|
|
93
|
+
}
|
|
94
|
+
existing.baseSha256 = baseSha256;
|
|
95
|
+
if (openkitVersion) existing.lastAppliedOpenkitVersion = openkitVersion;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function planUpgrade({
|
|
99
|
+
templateOpencodeDir,
|
|
100
|
+
projectDir,
|
|
101
|
+
manifestPath,
|
|
102
|
+
isTTY,
|
|
103
|
+
failOnChanges = false
|
|
104
|
+
}) {
|
|
105
|
+
const manifest = await readManifest(manifestPath);
|
|
106
|
+
const baseShaByPath = manifestBaseShaByPath(manifest);
|
|
107
|
+
const hasManifest = Boolean(manifest);
|
|
108
|
+
|
|
109
|
+
const templateAbsFiles = await listFilesRecursive(templateOpencodeDir);
|
|
110
|
+
const operations = [];
|
|
111
|
+
|
|
112
|
+
const templateManagedPaths = new Set();
|
|
113
|
+
for (const absTemplatePath of templateAbsFiles) {
|
|
114
|
+
const relInOpencode = toPosix(path.relative(templateOpencodeDir, absTemplatePath));
|
|
115
|
+
assertSafeRelativePath(relInOpencode);
|
|
116
|
+
const managedPath = toPosix(path.posix.join('.opencode', relInOpencode));
|
|
117
|
+
templateManagedPaths.add(managedPath);
|
|
118
|
+
|
|
119
|
+
const destAbs = path.join(projectDir, managedPath);
|
|
120
|
+
const templateContent = await fs.readFile(absTemplatePath);
|
|
121
|
+
const templateSha = sha256Hex(templateContent);
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
const currentContent = await fs.readFile(destAbs);
|
|
125
|
+
const currentSha = sha256Hex(currentContent);
|
|
126
|
+
if (!hasManifest) {
|
|
127
|
+
operations.push({
|
|
128
|
+
type: 'skip',
|
|
129
|
+
path: managedPath,
|
|
130
|
+
reason: isTTY ? 'legacy-existing-interactive' : 'legacy-existing-noninteractive',
|
|
131
|
+
templateSha,
|
|
132
|
+
currentSha
|
|
133
|
+
});
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const baseSha = baseShaByPath.get(managedPath);
|
|
138
|
+
if (baseSha && currentSha === baseSha) {
|
|
139
|
+
operations.push({
|
|
140
|
+
type: 'update',
|
|
141
|
+
path: managedPath,
|
|
142
|
+
reason: 'matches-manifest-base',
|
|
143
|
+
templateSha,
|
|
144
|
+
currentSha,
|
|
145
|
+
baseSha
|
|
146
|
+
});
|
|
147
|
+
} else {
|
|
148
|
+
operations.push({
|
|
149
|
+
type: 'conflict',
|
|
150
|
+
path: managedPath,
|
|
151
|
+
reason: baseSha ? 'differs-from-manifest-base' : 'missing-manifest-entry',
|
|
152
|
+
templateSha,
|
|
153
|
+
currentSha,
|
|
154
|
+
baseSha: baseSha || null
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
} catch (err) {
|
|
158
|
+
if (!err || err.code !== 'ENOENT') throw err;
|
|
159
|
+
operations.push({
|
|
160
|
+
type: 'add',
|
|
161
|
+
path: managedPath,
|
|
162
|
+
reason: 'missing-on-disk',
|
|
163
|
+
templateSha
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const orphaned = [];
|
|
169
|
+
if (hasManifest) {
|
|
170
|
+
for (const p of baseShaByPath.keys()) {
|
|
171
|
+
if (p.startsWith('.opencode/') && !templateManagedPaths.has(p)) {
|
|
172
|
+
orphaned.push(p);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
for (const p of orphaned.sort()) {
|
|
177
|
+
operations.push({ type: 'orphaned', path: p, reason: 'missing-from-template' });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const summary = {
|
|
181
|
+
added: operations.filter((o) => o.type === 'add').map((o) => o.path),
|
|
182
|
+
updated: operations.filter((o) => o.type === 'update').map((o) => o.path),
|
|
183
|
+
skipped: operations.filter((o) => o.type === 'skip').map((o) => o.path),
|
|
184
|
+
conflicts: operations.filter((o) => o.type === 'conflict').map((o) => o.path),
|
|
185
|
+
orphaned: operations.filter((o) => o.type === 'orphaned').map((o) => o.path)
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const exitCode = summary.conflicts.length > 0 && failOnChanges ? 2 : 0;
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
mode: hasManifest ? 'manifest' : 'legacy',
|
|
192
|
+
operations,
|
|
193
|
+
summary,
|
|
194
|
+
exitCode
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function applyUpgrade(plan, {
|
|
199
|
+
templateOpencodeDir,
|
|
200
|
+
projectDir,
|
|
201
|
+
manifestPath,
|
|
202
|
+
dryRun = false,
|
|
203
|
+
openkitVersion = null,
|
|
204
|
+
failOnChanges = false
|
|
205
|
+
} = {}) {
|
|
206
|
+
const conflicts = plan?.summary?.conflicts?.length ? plan.summary.conflicts : [];
|
|
207
|
+
const exitCode = conflicts.length > 0 && failOnChanges ? 2 : 0;
|
|
208
|
+
if (dryRun) {
|
|
209
|
+
return { ...plan, exitCode, wrote: false };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const manifest = (await readManifest(manifestPath)) || {
|
|
213
|
+
schemaVersion: 1,
|
|
214
|
+
managedBy: '@paulojalowyj/openkit',
|
|
215
|
+
openkitVersion: openkitVersion || null,
|
|
216
|
+
projectRoot: '.',
|
|
217
|
+
managedPaths: ['.opencode'],
|
|
218
|
+
files: [],
|
|
219
|
+
history: []
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
if (!Array.isArray(manifest.files)) {
|
|
223
|
+
manifest.files = [];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
for (const op of plan.operations) {
|
|
227
|
+
if (op.type !== 'add' && op.type !== 'update') continue;
|
|
228
|
+
const relInOpencode = op.path.replace(/^\.opencode\//, '');
|
|
229
|
+
assertSafeRelativePath(relInOpencode);
|
|
230
|
+
|
|
231
|
+
const srcAbs = path.join(templateOpencodeDir, relInOpencode);
|
|
232
|
+
const destAbs = path.join(projectDir, op.path);
|
|
233
|
+
await fs.mkdir(path.dirname(destAbs), { recursive: true });
|
|
234
|
+
const content = await fs.readFile(srcAbs);
|
|
235
|
+
await fs.writeFile(destAbs, content);
|
|
236
|
+
upsertManifestFileEntry(manifest.files, op.path, sha256Hex(content), openkitVersion);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
manifest.openkitVersion = openkitVersion || manifest.openkitVersion;
|
|
240
|
+
manifest.history = Array.isArray(manifest.history) ? manifest.history : [];
|
|
241
|
+
manifest.history.push({
|
|
242
|
+
type: 'upgrade',
|
|
243
|
+
openkitVersion: openkitVersion || null,
|
|
244
|
+
timestamp: new Date().toISOString()
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
await writeManifest(manifestPath, manifest);
|
|
248
|
+
|
|
249
|
+
return { ...plan, exitCode, wrote: true };
|
|
250
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@paulojalowyj/openkit",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.2.0",
|
|
5
5
|
"description": "OpenKit - OpenCode Agent System - 15 specialized prompts, 33+ skills, validation scripts\n\nQuick Start:\n1. Install: npx @paulojalowyj/openkit init\n2. Start OpenCode: opencode\n3. Use workflows: /engineer, /plan, /impl, /test\n\nDocumentation: https://opencode.ai/docs",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"bin": {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"scripts": {
|
|
11
11
|
"init": "node bin/cli.js init",
|
|
12
12
|
"doctor": "node bin/cli.js doctor",
|
|
13
|
-
"test": "
|
|
13
|
+
"test": "node --test test/*.test.js",
|
|
14
14
|
"prepublishOnly": "node scripts/prepare.js",
|
|
15
15
|
"version": "node scripts/update-version.js && git add -A"
|
|
16
16
|
},
|