@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 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 blueprintDir = path.join(__dirname, '..', 'blueprints', blueprintName);
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.1.1');
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,6 @@
1
+ import crypto from 'crypto';
2
+
3
+ export function sha256Hex(input) {
4
+ const buf = Buffer.isBuffer(input) ? input : Buffer.from(String(input));
5
+ return crypto.createHash('sha256').update(buf).digest('hex');
6
+ }
@@ -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.1.1",
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": "echo \"No tests yet - implement integration tests\"",
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
  },