optbrew 0.0.0-stage → 0.1.2
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/.claude-plugin/marketplace.json +6 -0
- package/.claude-plugin/plugin.json +6 -0
- package/.codex-plugin/plugin.json +13 -0
- package/CHANGELOG.md +50 -0
- package/LICENSE +21 -0
- package/README.ko.md +149 -0
- package/README.md +148 -2
- package/THIRD_PARTY_NOTICES.md +10 -0
- package/bin/optbrew.mjs +4 -0
- package/docs/platforms.md +107 -0
- package/docs/release.md +14 -0
- package/lib/cli.mjs +116 -0
- package/lib/doctor.mjs +45 -0
- package/lib/install.mjs +158 -0
- package/lib/paths.mjs +30 -0
- package/lib/records.mjs +44 -0
- package/lib/reinstall.mjs +92 -0
- package/lib/transaction.mjs +205 -0
- package/lib/update.mjs +189 -0
- package/package.json +39 -4
- package/plugin.json +13 -0
- package/skills/optbrew/LICENSE +21 -0
- package/skills/optbrew/SKILL.md +17 -0
- package/skills/optbrew/THIRD_PARTY_NOTICES.md +10 -0
- package/skills/optbrew/agents/openai.yaml +4 -0
- package/skills/optbrew/bundle-manifest.json +687 -0
- package/skills/optbrew/licenses/firecrawl-seo-audit.txt +15 -0
- package/skills/optbrew/licenses/magnus-seo.txt +21 -0
- package/skills/optbrew/licenses/marketing-search.txt +21 -0
- package/skills/optbrew/licenses/mblode-seo.txt +21 -0
- package/skills/optbrew/licenses/naver-searchadvisor.txt +29 -0
- package/skills/optbrew/licenses/seranking-seo.txt +21 -0
- package/skills/optbrew/modules/firecrawl-seo-audit/GUIDE.md +64 -0
- package/skills/optbrew/modules/magnus-seo/GUIDE.md +79 -0
- package/skills/optbrew/modules/magnus-seo/README.md +45 -0
- package/skills/optbrew/modules/magnus-seo/assets/audit-report-template.md +76 -0
- package/skills/optbrew/modules/magnus-seo/references/aeo-methodology.md +105 -0
- package/skills/optbrew/modules/magnus-seo/references/agent-readable-content.md +28 -0
- package/skills/optbrew/modules/magnus-seo/references/content-and-entity-architecture.md +100 -0
- package/skills/optbrew/modules/magnus-seo/references/content-strategy-seo.md +142 -0
- package/skills/optbrew/modules/magnus-seo/references/discovery-and-freshness.md +47 -0
- package/skills/optbrew/modules/magnus-seo/references/evidence-boundaries.md +75 -0
- package/skills/optbrew/modules/magnus-seo/references/ghost-metadata.md +204 -0
- package/skills/optbrew/modules/magnus-seo/references/implementation-playbook.md +114 -0
- package/skills/optbrew/modules/magnus-seo/references/measurement-and-experimentation.md +47 -0
- package/skills/optbrew/modules/magnus-seo/references/onpage-seo.md +154 -0
- package/skills/optbrew/modules/magnus-seo/references/platform-guidance.md +30 -0
- package/skills/optbrew/modules/magnus-seo/references/schema-markup.md +244 -0
- package/skills/optbrew/modules/magnus-seo/references/source-index.md +36 -0
- package/skills/optbrew/modules/magnus-seo/references/structured-data.md +56 -0
- package/skills/optbrew/modules/magnus-seo/references/technical-seo.md +99 -0
- package/skills/optbrew/modules/magnus-seo/scripts/aeo_audit.py +158 -0
- package/skills/optbrew/modules/magnus-seo/scripts/build_prompt_matrix.py +52 -0
- package/skills/optbrew/modules/magnus-seo/templates/aeo-implementation-plan.md +53 -0
- package/skills/optbrew/modules/magnus-seo/templates/citation-observation-log.md +16 -0
- package/skills/optbrew/modules/magnus-seo/templates/llms.txt.template +15 -0
- package/skills/optbrew/modules/magnus-seo/templates/question-cluster.md +27 -0
- package/skills/optbrew/modules/magnus-seo/templates/robots-ai-crawlers.txt +23 -0
- package/skills/optbrew/modules/marketing-search/skills/ai-seo/GUIDE.md +483 -0
- package/skills/optbrew/modules/marketing-search/skills/ai-seo/references/agent-readiness.md +57 -0
- package/skills/optbrew/modules/marketing-search/skills/ai-seo/references/citations-vs-recommendations.md +85 -0
- package/skills/optbrew/modules/marketing-search/skills/ai-seo/references/content-patterns.md +287 -0
- package/skills/optbrew/modules/marketing-search/skills/ai-seo/references/content-types.md +71 -0
- package/skills/optbrew/modules/marketing-search/skills/ai-seo/references/format-volatility.md +98 -0
- package/skills/optbrew/modules/marketing-search/skills/ai-seo/references/okf.md +104 -0
- package/skills/optbrew/modules/marketing-search/skills/ai-seo/references/platform-ranking-factors.md +154 -0
- package/skills/optbrew/modules/marketing-search/skills/ai-seo/references/youtube-ai-citations.md +60 -0
- package/skills/optbrew/modules/marketing-search/skills/analytics/GUIDE.md +303 -0
- package/skills/optbrew/modules/marketing-search/skills/analytics/references/event-library.md +260 -0
- package/skills/optbrew/modules/marketing-search/skills/analytics/references/ga4-implementation.md +300 -0
- package/skills/optbrew/modules/marketing-search/skills/analytics/references/gtm-implementation.md +390 -0
- package/skills/optbrew/modules/marketing-search/skills/content-strategy/GUIDE.md +432 -0
- package/skills/optbrew/modules/marketing-search/skills/content-strategy/references/content-distribution.md +83 -0
- package/skills/optbrew/modules/marketing-search/skills/content-strategy/references/headless-cms.md +194 -0
- package/skills/optbrew/modules/marketing-search/skills/programmatic-seo/GUIDE.md +231 -0
- package/skills/optbrew/modules/marketing-search/skills/programmatic-seo/references/playbooks.md +308 -0
- package/skills/optbrew/modules/marketing-search/skills/schema/GUIDE.md +172 -0
- package/skills/optbrew/modules/marketing-search/skills/schema/references/schema-examples.md +398 -0
- package/skills/optbrew/modules/marketing-search/skills/seo-audit/GUIDE.md +492 -0
- package/skills/optbrew/modules/marketing-search/skills/seo-audit/references/ai-writing-detection.md +200 -0
- package/skills/optbrew/modules/marketing-search/skills/seo-audit/references/international-seo.md +230 -0
- package/skills/optbrew/modules/marketing-search/skills/site-architecture/GUIDE.md +350 -0
- package/skills/optbrew/modules/marketing-search/skills/site-architecture/references/mermaid-templates.md +216 -0
- package/skills/optbrew/modules/marketing-search/skills/site-architecture/references/navigation-patterns.md +305 -0
- package/skills/optbrew/modules/marketing-search/skills/site-architecture/references/site-type-templates.md +293 -0
- package/skills/optbrew/modules/mblode-seo/GUIDE.md +74 -0
- package/skills/optbrew/modules/mblode-seo/references/answer-engines.md +58 -0
- package/skills/optbrew/modules/mblode-seo/references/audit.md +78 -0
- package/skills/optbrew/modules/mblode-seo/references/brief-template.md +47 -0
- package/skills/optbrew/modules/mblode-seo/references/monitoring.md +56 -0
- package/skills/optbrew/modules/mblode-seo/references/nextjs-implementation.md +65 -0
- package/skills/optbrew/modules/mblode-seo/references/research-protocol.md +37 -0
- package/skills/optbrew/modules/mblode-seo/references/sources.md +47 -0
- package/skills/optbrew/modules/mblode-seo/references/validation-evidence.md +21 -0
- package/skills/optbrew/modules/naver-searchadvisor/GUIDE.md +75 -0
- package/skills/optbrew/modules/naver-searchadvisor/NOTICE.md +59 -0
- package/skills/optbrew/modules/naver-searchadvisor/references/INDEX.md +97 -0
- package/skills/optbrew/modules/naver-searchadvisor/scripts/audit.mjs +742 -0
- package/skills/optbrew/modules/naver-searchadvisor/scripts/robots.mjs +77 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-ai-search-share-of-voice/GUIDE.md +94 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-api/GUIDE.md +186 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-api/references/api-surface-map.md +207 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-api/references/auth-and-keys.md +182 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-api/references/integration-patterns.md +283 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-api/references/rate-limits-and-credits.md +235 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-competitor-gap-analysis/GUIDE.md +117 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-content-audit/GUIDE.md +155 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-content-audit/references/cite.md +84 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-content-audit/references/core-eeat.md +147 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-content-audit/templates/verdict.md +63 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-firecrawl/GUIDE.md +141 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-firecrawl/references/preflight.md +73 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-geo/GUIDE.md +159 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-google/GUIDE.md +393 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-google/assets/templates/cwv-audit-report.md +48 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-google/assets/templates/gsc-performance-report.md +44 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-google/assets/templates/indexation-status-report.md +43 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/auth-setup.md +160 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/cross-skill-integration.md +187 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/ga4-data-api.md +184 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/indexing-api.md +109 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/keyword-planner-api.md +66 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/nlp-api.md +55 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/pagespeed-crux-api.md +204 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/rate-limits-quotas.md +75 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/search-console-api.md +156 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/supplementary-apis.md +99 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/youtube-api.md +49 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-keyword-niche/GUIDE.md +174 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-page/GUIDE.md +174 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-plan/GUIDE.md +220 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-schema/GUIDE.md +135 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-schema/references/google-rich-results.md +89 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-schema/templates/article.json +23 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-schema/templates/breadcrumb-list.json +18 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-schema/templates/faq-page.json +22 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-schema/templates/local-business.json +32 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-schema/templates/product.json +25 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-sitemap/GUIDE.md +128 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-technical-audit/GUIDE.md +235 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-technical-audit/references/severity-mapping.md +133 -0
- package/skills/optbrew/references/ai-crawlers.json +21 -0
- package/skills/optbrew/references/ai-crawlers.md +24 -0
- package/skills/optbrew/references/api.md +45 -0
- package/skills/optbrew/references/check-registry.json +28 -0
- package/skills/optbrew/references/evidence.md +9 -0
- package/skills/optbrew/references/intake.md +9 -0
- package/skills/optbrew/references/measurement.md +29 -0
- package/skills/optbrew/references/policies.md +7 -0
- package/skills/optbrew/references/profiles.json +13 -0
- package/skills/optbrew/references/remediations.json +12 -0
- package/skills/optbrew/references/report.md +29 -0
- package/skills/optbrew/references/routing.md +15 -0
- package/skills/optbrew/references/schemas/contract.schema.json +51 -0
- package/skills/optbrew/references/scoring.md +62 -0
- package/skills/optbrew/scripts/adapters/anthropic.mjs +116 -0
- package/skills/optbrew/scripts/adapters/imports.mjs +81 -0
- package/skills/optbrew/scripts/audit.mjs +124 -0
- package/skills/optbrew/scripts/cli.mjs +41 -0
- package/skills/optbrew/scripts/core/bootstrap.mjs +31 -0
- package/skills/optbrew/scripts/core/bundle.mjs +73 -0
- package/skills/optbrew/scripts/core/capabilities.mjs +19 -0
- package/skills/optbrew/scripts/core/collector.mjs +250 -0
- package/skills/optbrew/scripts/core/compare.mjs +47 -0
- package/skills/optbrew/scripts/core/contracts.mjs +144 -0
- package/skills/optbrew/scripts/core/cost-ledger.mjs +95 -0
- package/skills/optbrew/scripts/core/crawlers.mjs +11 -0
- package/skills/optbrew/scripts/core/derive.mjs +168 -0
- package/skills/optbrew/scripts/core/facts.mjs +189 -0
- package/skills/optbrew/scripts/core/findings.mjs +54 -0
- package/skills/optbrew/scripts/core/html.mjs +53 -0
- package/skills/optbrew/scripts/core/metrics.mjs +97 -0
- package/skills/optbrew/scripts/core/protocol.mjs +125 -0
- package/skills/optbrew/scripts/core/report.mjs +742 -0
- package/skills/optbrew/scripts/core/scope.mjs +52 -0
- package/skills/optbrew/scripts/core/score.mjs +71 -0
- package/skills/optbrew/scripts/core/weights.mjs +139 -0
- package/skills/optbrew/source-lock.json +2686 -0
- package/source-lock.json +2686 -0
package/lib/cli.mjs
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { install } from './install.mjs';
|
|
5
|
+
import { doctor } from './doctor.mjs';
|
|
6
|
+
import { supportedAgents, scopePaths } from './paths.mjs';
|
|
7
|
+
import { checkUpdates, update, rollback } from './update.mjs';
|
|
8
|
+
import { readFile } from 'node:fs/promises';
|
|
9
|
+
import { readInstallRecord } from './records.mjs';
|
|
10
|
+
import { runCli } from '../skills/optbrew/scripts/cli.mjs';
|
|
11
|
+
import { reinstallPlan } from './reinstall.mjs';
|
|
12
|
+
|
|
13
|
+
const defaultBundle = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../skills/optbrew');
|
|
14
|
+
class UsageError extends Error {}
|
|
15
|
+
const usage = 'usage: optbrew <install|doctor|check-updates|update|rollback|reinstall-plan|score|compare|report> [options]';
|
|
16
|
+
|
|
17
|
+
function parseOptions(argv, allowed) {
|
|
18
|
+
const options = {};
|
|
19
|
+
for (let i = 0; i < argv.length; i++) {
|
|
20
|
+
const key = argv[i];
|
|
21
|
+
if (!allowed.includes(key) || key in options) throw new UsageError(`unknown or repeated option: ${key}`);
|
|
22
|
+
if (key === '--json' || key === '--dry-run') options[key] = true;
|
|
23
|
+
else {
|
|
24
|
+
if (!argv[i + 1] || argv[i + 1].startsWith('--')) throw new UsageError(`missing value for ${key}`);
|
|
25
|
+
options[key] = argv[++i];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return options;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function installOptions(argv, command, { cwd, env }) {
|
|
32
|
+
const allowed = ['--agents', '--scope', '--project', '--home', '--json'];
|
|
33
|
+
if (command === 'install') allowed.push('--dry-run', '--strategy');
|
|
34
|
+
const parsed = parseOptions(argv, allowed);
|
|
35
|
+
if (!parsed['--agents']) throw new UsageError('--agents is required');
|
|
36
|
+
const agents = parsed['--agents'].split(',');
|
|
37
|
+
if (agents.some(agent => !supportedAgents.includes(agent)) || new Set(agents).size !== agents.length) throw new UsageError('invalid or duplicate agent');
|
|
38
|
+
const scope = parsed['--scope'] ?? 'user';
|
|
39
|
+
if (!['user', 'project'].includes(scope)) throw new UsageError('--scope must be user or project');
|
|
40
|
+
if (scope === 'project' && !parsed['--project']) throw new UsageError('project scope requires --project');
|
|
41
|
+
if (scope === 'user' && parsed['--project']) throw new UsageError('--project requires project scope');
|
|
42
|
+
const strategy = parsed['--strategy'] ?? 'link';
|
|
43
|
+
if (!['link', 'copy'].includes(strategy)) throw new UsageError('--strategy must be link or copy');
|
|
44
|
+
return { agents, scope, home: parsed['--home'] ? path.resolve(cwd, parsed['--home']) : env?.HOME ?? os.homedir(),
|
|
45
|
+
projectRoot: parsed['--project'] ? path.resolve(cwd, parsed['--project']) : undefined,
|
|
46
|
+
dryRun: Boolean(parsed['--dry-run']), strategy, json: Boolean(parsed['--json']) };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function computeArgs(argv, command, cwd) {
|
|
50
|
+
const keys = command === 'score' ? ['--input', '--output'] : command === 'compare' ? ['--before', '--after', '--output'] : ['--input', '--output-dir'];
|
|
51
|
+
const opts = parseOptions(argv, keys);
|
|
52
|
+
if (keys.some(k => !opts[k])) throw new UsageError(`missing ${keys.filter(k => !opts[k]).join(', ')}`);
|
|
53
|
+
return [command, ...keys.flatMap(k => [k, path.resolve(cwd, opts[k])])];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Return CLI process code: 0 success, 2 argument error, 1 environment or file failure. */
|
|
57
|
+
export async function main(argv, { stdout = process.stdout, stderr = process.stderr, env = process.env, cwd = process.cwd(), bundleRoot = defaultBundle, registryTransport } = {}) {
|
|
58
|
+
try {
|
|
59
|
+
const [command, ...rest] = argv;
|
|
60
|
+
if (['score', 'compare', 'report'].includes(command)) {
|
|
61
|
+
await runCli(computeArgs(rest, command, cwd));
|
|
62
|
+
stdout.write(`${command} complete\n`);
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
if (!['install', 'doctor', 'check-updates', 'update', 'rollback', 'reinstall-plan'].includes(command)) throw new UsageError(usage);
|
|
66
|
+
if (command === 'reinstall-plan') {
|
|
67
|
+
// Read-only: validates the install record and prints what the documented manual reinstall may delete.
|
|
68
|
+
const parsed = parseOptions(rest, ['--scope', '--home', '--project']);
|
|
69
|
+
const scope = parsed['--scope'] ?? 'user';
|
|
70
|
+
if (!['user', 'project'].includes(scope)) throw new UsageError('--scope must be user or project');
|
|
71
|
+
if (scope === 'project' && !parsed['--project']) throw new UsageError('project scope requires --project');
|
|
72
|
+
if (scope === 'user' && parsed['--project']) throw new UsageError('--project requires project scope');
|
|
73
|
+
const rows = await reinstallPlan({ scope, home: parsed['--home'] ? path.resolve(cwd, parsed['--home']) : env?.HOME ?? os.homedir(),
|
|
74
|
+
projectRoot: parsed['--project'] ? path.resolve(cwd, parsed['--project']) : undefined });
|
|
75
|
+
stdout.write(`${rows.map(row => row.join('\t')).join('\n')}\n`);
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
if (command === 'check-updates') {
|
|
79
|
+
const parsed = parseOptions(rest, ['--package', '--json']);
|
|
80
|
+
const manifest = JSON.parse(await readFile(path.join(bundleRoot, 'bundle-manifest.json'), 'utf8'));
|
|
81
|
+
const result = await checkUpdates({ packageName: parsed['--package'], currentVersion: manifest.release_version, registryTransport });
|
|
82
|
+
stdout.write(`${JSON.stringify(result, null, parsed['--json'] ? 2 : 0)}\n`);
|
|
83
|
+
return 0;
|
|
84
|
+
}
|
|
85
|
+
if (command === 'update' || command === 'rollback') {
|
|
86
|
+
const allowed = ['--record-root', '--scope', '--home', '--project', '--agents', '--json'];
|
|
87
|
+
allowed.push(command === 'update' ? '--local-changes' : '--release-version');
|
|
88
|
+
const parsed = parseOptions(rest, allowed);
|
|
89
|
+
if (parsed['--record-root'] && ['--scope', '--home', '--project'].some(k => parsed[k])) throw new UsageError('--record-root cannot be combined with scope options');
|
|
90
|
+
const scope = parsed['--scope'] ?? 'user';
|
|
91
|
+
if (scope === 'user' && parsed['--project']) throw new UsageError('--project requires project scope');
|
|
92
|
+
const recordRoot = parsed['--record-root'] ? path.resolve(cwd, parsed['--record-root']) : scopePaths({ scope,
|
|
93
|
+
home: parsed['--home'] ? path.resolve(cwd, parsed['--home']) : env?.HOME ?? os.homedir(),
|
|
94
|
+
projectRoot: parsed['--project'] ? path.resolve(cwd, parsed['--project']) : undefined }).managedRoot;
|
|
95
|
+
if (parsed['--agents']) {
|
|
96
|
+
const selected = parsed['--agents'].split(',');
|
|
97
|
+
if (selected.some(agent => !supportedAgents.includes(agent)) || new Set(selected).size !== selected.length) throw new UsageError('invalid or duplicate agent');
|
|
98
|
+
const record = await readInstallRecord(recordRoot);
|
|
99
|
+
if (!record) throw new Error('install record missing');
|
|
100
|
+
if (JSON.stringify(selected.sort()) !== JSON.stringify(record.targets.map(t => t.agent).sort())) throw new UsageError('update/rollback requires all recorded targets; partial updates are unavailable');
|
|
101
|
+
}
|
|
102
|
+
if (command === 'rollback' && !parsed['--release-version']) throw new UsageError('--release-version is required');
|
|
103
|
+
const result = command === 'update' ? await update({ bundleRoot, recordRoot, localChanges: parsed['--local-changes'] ?? 'abort' }) :
|
|
104
|
+
await rollback({ recordRoot, releaseVersion: parsed['--release-version'] });
|
|
105
|
+
stdout.write(`${JSON.stringify(result, null, parsed['--json'] ? 2 : 0)}\n`);
|
|
106
|
+
return ['updated', 'rolled_back', 'unchanged'].includes(result.status) ? 0 : 1;
|
|
107
|
+
}
|
|
108
|
+
const options = installOptions(rest, command, { cwd, env });
|
|
109
|
+
const result = command === 'install' ? await install({ ...options, bundleRoot }) : await doctor(options);
|
|
110
|
+
stdout.write(options.json ? `${JSON.stringify(result, null, 2)}\n` : `${JSON.stringify(result)}\n`);
|
|
111
|
+
return 0;
|
|
112
|
+
} catch (error) {
|
|
113
|
+
stderr.write(`${error.message}\n`);
|
|
114
|
+
return error instanceof UsageError || error instanceof TypeError ? 2 : 1;
|
|
115
|
+
}
|
|
116
|
+
}
|
package/lib/doctor.mjs
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { lstat, readFile, realpath } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { verifyBundle } from '../skills/optbrew/scripts/core/bundle.mjs';
|
|
4
|
+
import { readInstallRecord } from './records.mjs';
|
|
5
|
+
import { resolveTargets, scopePaths } from './paths.mjs';
|
|
6
|
+
import { validateScope } from './transaction.mjs';
|
|
7
|
+
|
|
8
|
+
async function exists(name) {
|
|
9
|
+
try { await lstat(name); return true; } catch (error) { if (error.code === 'ENOENT') return false; throw error; }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function doctor({ agents, scope, home, projectRoot }) {
|
|
13
|
+
const { scopeRoot, managedRoot } = scopePaths({ scope, home, projectRoot });
|
|
14
|
+
const selected = resolveTargets({ agents, scope, home, projectRoot });
|
|
15
|
+
const warnings = [];
|
|
16
|
+
let record;
|
|
17
|
+
try { record = await readInstallRecord(managedRoot); if (record) await validateScope(managedRoot, record); }
|
|
18
|
+
catch (error) { warnings.push(`install record unreadable: ${error.message}`); record = null; }
|
|
19
|
+
const recoveryPending = await exists(path.join(managedRoot, 'transaction.json'));
|
|
20
|
+
if (recoveryPending) warnings.push('recovery_required: interrupted transaction; rerun update/rollback to recover before use');
|
|
21
|
+
if (record?.transaction?.state === 'pending') warnings.push('initial install pending: finish optbrew install before updating');
|
|
22
|
+
const results = [];
|
|
23
|
+
for (const target of selected) {
|
|
24
|
+
const saved = record?.targets.find(t => t.agent === target.agent && t.path === target.path);
|
|
25
|
+
const filePresent = await exists(path.join(target.path, 'SKILL.md'));
|
|
26
|
+
let hashOk = false;
|
|
27
|
+
if (filePresent && saved && record) {
|
|
28
|
+
try {
|
|
29
|
+
if (saved.strategy === 'link' && await realpath(target.path) !== await realpath(path.join(managedRoot, 'bundle'))) throw new Error('link target changed');
|
|
30
|
+
if (saved.strategy === 'copy' && (await lstat(target.path)).isSymbolicLink()) throw new Error('copy replaced by link');
|
|
31
|
+
const manifest = JSON.parse(await readFile(path.join(target.path, 'bundle-manifest.json'), 'utf8'));
|
|
32
|
+
const check = await verifyBundle({ bundleRoot: target.path, manifest });
|
|
33
|
+
if (check.errors.some(e => e.code === 'migration_required')) warnings.push(`${target.agent}: installed release ${manifest.release_version} was built for another evaluation version; update is refused (migration_required). See docs/platforms.md, "Reinstall after a migration_required update".`);
|
|
34
|
+
hashOk = !recoveryPending && record.transaction.state === 'committed' && check.status === 'valid' && check.bundle_hash === record.bundle_hash && manifest.release_version === record.release_version;
|
|
35
|
+
} catch (error) { warnings.push(`${target.agent}: ${error.message}`); }
|
|
36
|
+
}
|
|
37
|
+
results.push({ agent: target.agent, path: target.path, file_present: filePresent, hash_ok: hashOk,
|
|
38
|
+
registered: Boolean(saved), session_discovery: 'not_verified', invocation_verified: 'not_verified' });
|
|
39
|
+
}
|
|
40
|
+
if (agents.includes('codex') || agents.includes('cursor') || agents.includes('opencode') || agents.includes('pi')) {
|
|
41
|
+
warnings.push('Shared .agents skill paths may also be discovered by other hosts; only selected targets are registered here.');
|
|
42
|
+
}
|
|
43
|
+
if (agents.includes('pi')) warnings.push('Pi custom PI_CODING_AGENT_DIR and project trust settings are not inspected.');
|
|
44
|
+
return { scope_id: `${scope}:${scopeRoot}`, release_version: record?.release_version ?? null, targets: results, warnings };
|
|
45
|
+
}
|
package/lib/install.mjs
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { cp, lstat, mkdir, open, readFile, realpath, rename, rm, symlink } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { isDistributedBundlePath, verifyBundle } from '../skills/optbrew/scripts/core/bundle.mjs';
|
|
4
|
+
import { readInstallRecord, writeInstallRecord } from './records.mjs';
|
|
5
|
+
import { resolveTargets, scopePaths } from './paths.mjs';
|
|
6
|
+
|
|
7
|
+
async function statOrNull(name) {
|
|
8
|
+
try { return await lstat(name); } catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function rejectLinkedAncestors(scopeRoot, destination) {
|
|
12
|
+
const relative = path.relative(scopeRoot, destination);
|
|
13
|
+
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) throw new Error(`outside scope: ${destination}`);
|
|
14
|
+
let current = scopeRoot;
|
|
15
|
+
if ((await statOrNull(current))?.isSymbolicLink()) throw new Error(`scope root is a link: ${current}`);
|
|
16
|
+
for (const part of relative.split(path.sep).slice(0, -1)) {
|
|
17
|
+
current = path.join(current, part);
|
|
18
|
+
if ((await statOrNull(current))?.isSymbolicLink()) throw new Error(`junction/link ancestor outside trusted scope: ${current}`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function verifiedManifest(bundleRoot) {
|
|
23
|
+
if ((await lstat(bundleRoot)).isSymbolicLink()) throw new Error(`bundle root is a link: ${bundleRoot}`);
|
|
24
|
+
const manifest = JSON.parse(await readFile(path.join(bundleRoot, 'bundle-manifest.json'), 'utf8'));
|
|
25
|
+
const verification = await verifyBundle({ bundleRoot, manifest });
|
|
26
|
+
if (verification.status !== 'valid') throw new Error(`bundle integrity failed: ${verification.errors.map(e => `${e.path}:${e.code}`).join(', ')}`);
|
|
27
|
+
return { manifest, verification };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function targetOwned(target, record, managedBundle) {
|
|
31
|
+
const existing = await statOrNull(target.path);
|
|
32
|
+
const saved = record?.targets.find(item => item.agent === target.agent && item.path === target.path);
|
|
33
|
+
if (!existing) {
|
|
34
|
+
if (saved) throw new Error(`registered target missing: ${target.path}`);
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
if (!saved || saved.managed_target !== managedBundle) throw new Error(`unmanaged optbrew target exists: ${target.path}`);
|
|
38
|
+
if (saved.strategy === 'link') {
|
|
39
|
+
if (!existing.isSymbolicLink() || await realpath(target.path) !== await realpath(managedBundle)) throw new Error(`registered link changed: ${target.path}`);
|
|
40
|
+
} else if (saved.strategy === 'copy') {
|
|
41
|
+
if (!existing.isDirectory() || existing.isSymbolicLink()) throw new Error(`registered copy changed: ${target.path}`);
|
|
42
|
+
const check = await verifiedManifest(target.path);
|
|
43
|
+
if (check.manifest.bundle_hash !== record.bundle_hash) throw new Error(`registered copy changed: ${target.path}`);
|
|
44
|
+
} else throw new Error(`invalid recorded strategy: ${saved.strategy}`);
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Initial install and additive target registration; version changes belong to update. */
|
|
49
|
+
export async function install({ bundleRoot, agents, scope, home, projectRoot, dryRun = false, strategy = 'link' }) {
|
|
50
|
+
if (!['link', 'copy'].includes(strategy)) throw new TypeError('strategy must be link or copy');
|
|
51
|
+
const { scopeRoot, managedRoot } = scopePaths({ scope, home, projectRoot });
|
|
52
|
+
const targets = resolveTargets({ agents, scope, home, projectRoot }).map(t => ({ ...t, strategy }));
|
|
53
|
+
const source = await verifiedManifest(bundleRoot);
|
|
54
|
+
for (const target of targets) await rejectLinkedAncestors(scopeRoot, target.path);
|
|
55
|
+
await rejectLinkedAncestors(scopeRoot, path.join(managedRoot, 'bundle'));
|
|
56
|
+
|
|
57
|
+
async function inspect() {
|
|
58
|
+
if (await statOrNull(path.join(managedRoot, 'transaction.json'))) {
|
|
59
|
+
// Single-quoted literals: neither PowerShell nor a POSIX shell expands $ or backticks inside them.
|
|
60
|
+
const powershell = `'${managedRoot.replaceAll("'", "''")}'`, posix = `'${managedRoot.replaceAll("'", "'\\''")}'`;
|
|
61
|
+
throw new Error(`recovery_required: interrupted transaction; run optbrew update before install. PowerShell: optbrew update --record-root ${powershell}; POSIX shell: optbrew update --record-root ${posix}`);
|
|
62
|
+
}
|
|
63
|
+
const record = await readInstallRecord(managedRoot);
|
|
64
|
+
if (record) {
|
|
65
|
+
if (record.managed_root !== managedRoot || record.scope_id !== `${scope}:${scopeRoot}`) throw new Error('install record scope mismatch');
|
|
66
|
+
if (record.bundle_hash !== source.manifest.bundle_hash || record.release_version !== source.manifest.release_version) throw new Error('existing release differs; use optbrew update');
|
|
67
|
+
const managed = await verifiedManifest(path.join(managedRoot, 'bundle'));
|
|
68
|
+
if (managed.manifest.bundle_hash !== record.bundle_hash) throw new Error('managed bundle differs from record');
|
|
69
|
+
for (const saved of record.targets) {
|
|
70
|
+
const canonical = resolveTargets({ agents: [saved.agent], scope, home, projectRoot })[0];
|
|
71
|
+
if (saved.path !== canonical.path || saved.managed_target !== canonical.managed_target) throw new Error('install record target path mismatch');
|
|
72
|
+
await rejectLinkedAncestors(scopeRoot, saved.path);
|
|
73
|
+
await targetOwned(saved, record, path.join(managedRoot, 'bundle'));
|
|
74
|
+
}
|
|
75
|
+
} else if (await statOrNull(path.join(managedRoot, 'bundle'))) throw new Error('unmanaged bundle exists');
|
|
76
|
+
for (const target of targets) await targetOwned(target, record, path.join(managedRoot, 'bundle'));
|
|
77
|
+
return record;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const before = await inspect();
|
|
81
|
+
const pending = targets.filter(t => !before?.targets.some(saved => saved.agent === t.agent));
|
|
82
|
+
const decision = { strategy, targets, pending: pending.map(t => t.path), release_version: source.manifest.release_version };
|
|
83
|
+
if (dryRun) return { status: 'dry_run', dry_run: true, record: null, changed_paths: [], decision, verification: source.verification };
|
|
84
|
+
|
|
85
|
+
await mkdir(managedRoot, { recursive: true });
|
|
86
|
+
const lockPath = path.join(managedRoot, 'install.lock');
|
|
87
|
+
let lock;
|
|
88
|
+
try { lock = await open(lockPath, 'wx'); }
|
|
89
|
+
catch (error) { if (error.code === 'EEXIST') throw new Error('scope install lock already held'); throw error; }
|
|
90
|
+
const changed = [];
|
|
91
|
+
try {
|
|
92
|
+
const record = await inspect();
|
|
93
|
+
const toAdd = targets.filter(t => !record?.targets.some(saved => saved.agent === t.agent));
|
|
94
|
+
const managedBundle = path.join(managedRoot, 'bundle');
|
|
95
|
+
let next = record;
|
|
96
|
+
if (!record) {
|
|
97
|
+
const staging = path.join(managedRoot, `bundle.${process.pid}.${Date.now()}.tmp`);
|
|
98
|
+
try {
|
|
99
|
+
await cp(bundleRoot, staging, { recursive: true, errorOnExist: true, force: false, dereference: false,
|
|
100
|
+
filter: sourcePath => isDistributedBundlePath(path.relative(bundleRoot, sourcePath)) });
|
|
101
|
+
const copied = await verifiedManifest(staging);
|
|
102
|
+
if (copied.manifest.bundle_hash !== source.manifest.bundle_hash) throw new Error('staged bundle changed');
|
|
103
|
+
await rename(staging, managedBundle);
|
|
104
|
+
changed.push(managedBundle);
|
|
105
|
+
} finally { await rm(staging, { recursive: true, force: true }); }
|
|
106
|
+
next = {
|
|
107
|
+
schema_version: '1.0.0', scope_id: `${scope}:${scopeRoot}`, release_version: source.manifest.release_version,
|
|
108
|
+
bundle_hash: source.manifest.bundle_hash, source: path.resolve(bundleRoot), managed_root: managedRoot,
|
|
109
|
+
targets: [], file_hashes: Object.fromEntries(source.manifest.files.map(f => [f.path, f.sha256])),
|
|
110
|
+
previous_releases: [], transaction: { state: 'pending' }, source_ref: 'bundle-manifest.json',
|
|
111
|
+
};
|
|
112
|
+
try { await writeInstallRecord(managedRoot, next); }
|
|
113
|
+
catch (error) {
|
|
114
|
+
// Only remove the bundle this invocation created, if no record took ownership of it.
|
|
115
|
+
if (!(await readInstallRecord(managedRoot)) &&
|
|
116
|
+
(await verifiedManifest(managedBundle)).manifest.bundle_hash === source.manifest.bundle_hash) {
|
|
117
|
+
await rm(managedBundle, { recursive: true, force: true });
|
|
118
|
+
}
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
for (const target of toAdd) {
|
|
123
|
+
await rejectLinkedAncestors(scopeRoot, target.path);
|
|
124
|
+
if (await statOrNull(target.path)) throw new Error(`unmanaged optbrew target exists: ${target.path}`);
|
|
125
|
+
await mkdir(path.dirname(target.path), { recursive: true });
|
|
126
|
+
if (strategy === 'link') {
|
|
127
|
+
try { await symlink(managedBundle, target.path, process.platform === 'win32' ? 'junction' : 'dir'); }
|
|
128
|
+
catch (error) { throw new Error(`link unavailable for ${target.path}; retry explicitly with --strategy copy (${error.message})`); }
|
|
129
|
+
} else {
|
|
130
|
+
const staging = `${target.path}.${process.pid}.${Date.now()}.tmp`;
|
|
131
|
+
try {
|
|
132
|
+
await cp(managedBundle, staging, { recursive: true, errorOnExist: true, force: false });
|
|
133
|
+
await verifiedManifest(staging);
|
|
134
|
+
await rename(staging, target.path);
|
|
135
|
+
} finally { await rm(staging, { recursive: true, force: true }); }
|
|
136
|
+
}
|
|
137
|
+
changed.push(target.path);
|
|
138
|
+
next.targets = [...next.targets, target];
|
|
139
|
+
next.transaction = { state: 'pending' };
|
|
140
|
+
try { await writeInstallRecord(managedRoot, next); }
|
|
141
|
+
catch (error) {
|
|
142
|
+
// A write failure leaves the previous record intact. Undo only this newly created target.
|
|
143
|
+
const current = await statOrNull(target.path);
|
|
144
|
+
if (current?.isSymbolicLink() && await realpath(target.path) === await realpath(managedBundle)) await rm(target.path);
|
|
145
|
+
else if (current?.isDirectory() && !current.isSymbolicLink() &&
|
|
146
|
+
(await verifiedManifest(target.path)).manifest.bundle_hash === source.manifest.bundle_hash) {
|
|
147
|
+
await rm(target.path, { recursive: true });
|
|
148
|
+
}
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (next.transaction.state !== 'committed') {
|
|
153
|
+
next.transaction = { state: 'committed' };
|
|
154
|
+
await writeInstallRecord(managedRoot, next);
|
|
155
|
+
}
|
|
156
|
+
return { status: changed.length ? 'installed' : 'unchanged', dry_run: false, record: next, changed_paths: changed, decision, verification: source.verification };
|
|
157
|
+
} finally { await lock.close(); await rm(lockPath, { force: true }); }
|
|
158
|
+
}
|
package/lib/paths.mjs
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
const locations = {
|
|
5
|
+
codex: ['.agents/skills/optbrew', '.agents/skills/optbrew'],
|
|
6
|
+
'claude-code': ['.claude/skills/optbrew', '.claude/skills/optbrew'],
|
|
7
|
+
cursor: ['.cursor/skills/optbrew', '.cursor/skills/optbrew'],
|
|
8
|
+
opencode: ['.config/opencode/skills/optbrew', '.opencode/skills/optbrew'],
|
|
9
|
+
pi: ['.pi/agent/skills/optbrew', '.pi/skills/optbrew'],
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const supportedAgents = Object.keys(locations);
|
|
13
|
+
|
|
14
|
+
export function scopePaths({ scope, home = os.homedir(), projectRoot }) {
|
|
15
|
+
if (scope !== 'user' && scope !== 'project') throw new TypeError('scope must be user or project');
|
|
16
|
+
if (scope === 'project' && !projectRoot) throw new TypeError('project scope requires --project');
|
|
17
|
+
const scopeRoot = path.resolve(scope === 'user' ? home : projectRoot);
|
|
18
|
+
return { scopeRoot, managedRoot: path.join(scopeRoot, scope === 'user' ? '.optbrew/installs/user' : '.optbrew/install') };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Fixed host paths confirmed against current host documentation. */
|
|
22
|
+
export function resolveTargets({ agents, scope, home, projectRoot }) {
|
|
23
|
+
const { scopeRoot, managedRoot } = scopePaths({ scope, home, projectRoot });
|
|
24
|
+
if (!Array.isArray(agents) || !agents.length) throw new TypeError('at least one agent required');
|
|
25
|
+
if (new Set(agents).size !== agents.length) throw new TypeError('duplicate agent');
|
|
26
|
+
return agents.map(agent => {
|
|
27
|
+
if (!locations[agent]) throw new TypeError(`unknown agent: ${agent}`);
|
|
28
|
+
return { agent, path: path.join(scopeRoot, locations[agent][scope === 'user' ? 0 : 1]), strategy: 'link', managed_target: path.join(managedRoot, 'bundle') };
|
|
29
|
+
});
|
|
30
|
+
}
|
package/lib/records.mjs
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { mkdir, readFile, open, rename, rm, lstat } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
export function validateInstallRecord(record) {
|
|
6
|
+
if (record?.schema_version !== '1.0.0') throw new Error('migration_required: unsupported install record schema; use a compatible optbrew release');
|
|
7
|
+
if (!Array.isArray(record.targets) || !/^[a-f0-9]{64}$/.test(record.bundle_hash) ||
|
|
8
|
+
typeof record.managed_root !== 'string' || typeof record.scope_id !== 'string' ||
|
|
9
|
+
typeof record.release_version !== 'string' || !record.file_hashes || typeof record.file_hashes !== 'object' ||
|
|
10
|
+
!Array.isArray(record.previous_releases) || !['pending', 'committed'].includes(record.transaction?.state)) throw new Error('invalid install record');
|
|
11
|
+
if (Object.entries(record.file_hashes).some(([name, hash]) => path.isAbsolute(name) || name.split(/[\\/]/).includes('..') || !/^[a-f0-9]{64}$/.test(hash))) throw new Error('invalid install record file hashes');
|
|
12
|
+
return record;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Flush bytes before atomic replacement; callers hold the scope lock. */
|
|
16
|
+
export async function writeDurableJson(file, value) {
|
|
17
|
+
const temp = `${file}.${randomUUID()}.tmp`;
|
|
18
|
+
try {
|
|
19
|
+
const handle = await open(temp, 'wx');
|
|
20
|
+
try { await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`); await handle.sync(); }
|
|
21
|
+
finally { await handle.close(); }
|
|
22
|
+
await rename(temp, file);
|
|
23
|
+
// Windows cannot open directories for fsync. File flush + rename remains atomic there.
|
|
24
|
+
if (process.platform !== 'win32') {
|
|
25
|
+
const directory = await open(path.dirname(file), 'r');
|
|
26
|
+
try { await directory.sync(); } finally { await directory.close(); }
|
|
27
|
+
}
|
|
28
|
+
} finally { await rm(temp, { force: true }); }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function readInstallRecord(scopeRoot) {
|
|
32
|
+
const file = path.join(scopeRoot, 'install-record.json');
|
|
33
|
+
try {
|
|
34
|
+
if ((await lstat(file)).isSymbolicLink()) throw new Error('install record is a link');
|
|
35
|
+
const record = JSON.parse(await readFile(file, 'utf8'));
|
|
36
|
+
return validateInstallRecord(record);
|
|
37
|
+
} catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function writeInstallRecord(scopeRoot, record) {
|
|
41
|
+
await mkdir(scopeRoot, { recursive: true });
|
|
42
|
+
const file = path.join(scopeRoot, 'install-record.json');
|
|
43
|
+
await writeDurableJson(file, record);
|
|
44
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { lstat, realpath } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { readInstallRecord } from './records.mjs';
|
|
4
|
+
import { scopePaths } from './paths.mjs';
|
|
5
|
+
import { validateScope, statOrNull } from './transaction.mjs';
|
|
6
|
+
|
|
7
|
+
const powershell = value => `'${String(value).replaceAll("'", "''")}'`;
|
|
8
|
+
const posix = value => `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Read-only plan for the manual reinstall of a managed scope. It validates the install record before anything is
|
|
12
|
+
* deleted: the record belongs to this scope, every recorded target is exactly the expected skills/optbrew path of its
|
|
13
|
+
* agent (absolute, no "..", no linked ancestor, inside the scope), and every target that exists is what the installer
|
|
14
|
+
* created (a link to the managed bundle, or a real directory holding a bundle manifest). Any failed check throws, so a
|
|
15
|
+
* caller that deletes only what this returns never deletes anything for a tampered record.
|
|
16
|
+
*
|
|
17
|
+
* Rows: `delete-link|delete-dir <path>` (recorded targets first, the managed root last), then `install <agent> <strategy>`
|
|
18
|
+
* per recorded agent in record order, then `agents <csv>` for the final doctor call.
|
|
19
|
+
*/
|
|
20
|
+
export async function reinstallPlan({ scope, home, projectRoot }) {
|
|
21
|
+
const refuse = message => { throw new Error(`reinstall plan refused: ${message}`); };
|
|
22
|
+
// One output line per path: a line break, tab or other control character would split a row or inject another one.
|
|
23
|
+
const plain = value => typeof value === 'string' && [...value].every(ch => { const c = ch.codePointAt(0); return c > 31 && (c < 127 || c > 159) && c !== 8232 && c !== 8233; });
|
|
24
|
+
const { scopeRoot, managedRoot } = scopePaths({ scope, home, projectRoot });
|
|
25
|
+
if (!plain(scopeRoot) || !plain(managedRoot)) refuse('the scope path contains a control character (a line break, tab or similar)');
|
|
26
|
+
const record = await readInstallRecord(managedRoot).catch(error => refuse(`install record unreadable (${error.message})`));
|
|
27
|
+
if (!record) refuse(`no install record in ${managedRoot}`);
|
|
28
|
+
if (record.scope_id !== `${scope}:${scopeRoot}` || record.managed_root !== managedRoot) refuse('the install record belongs to another scope');
|
|
29
|
+
if (!record.targets.length) refuse('the install record lists no target');
|
|
30
|
+
for (const target of record.targets) {
|
|
31
|
+
if (!plain(target.path) && typeof target.path === 'string') refuse('a recorded target path contains a control character (a line break, tab or similar)');
|
|
32
|
+
if (typeof target.path !== 'string' || !path.isAbsolute(target.path) || target.path.split(/[\\/]/).includes('..'))
|
|
33
|
+
refuse(`recorded target is not a plain absolute path: ${JSON.stringify(target.path)}`);
|
|
34
|
+
}
|
|
35
|
+
try { await validateScope(managedRoot, record); }
|
|
36
|
+
catch (error) { refuse(`${error.message} (expected exactly the agent's skills/optbrew path inside ${scopeRoot})`); }
|
|
37
|
+
const managedBundle = path.join(managedRoot, 'bundle');
|
|
38
|
+
const rows = [];
|
|
39
|
+
for (const target of record.targets) {
|
|
40
|
+
const found = await statOrNull(target.path);
|
|
41
|
+
if (!found) continue;
|
|
42
|
+
if (found.isSymbolicLink()) {
|
|
43
|
+
if (target.strategy !== 'link' || await realpath(target.path) !== await realpath(managedBundle))
|
|
44
|
+
refuse(`${target.path} is a link that does not lead to the managed bundle`);
|
|
45
|
+
rows.push(['delete-link', target.path]);
|
|
46
|
+
} else if (found.isDirectory()) {
|
|
47
|
+
if (target.strategy !== 'copy') refuse(`${target.path} was recorded as a link but is a directory`);
|
|
48
|
+
try { await lstat(path.join(target.path, 'bundle-manifest.json')); }
|
|
49
|
+
catch { refuse(`${target.path} holds no optbrew bundle manifest`); }
|
|
50
|
+
rows.push(['delete-dir', target.path]);
|
|
51
|
+
} else refuse(`${target.path} is neither a link nor a directory`);
|
|
52
|
+
}
|
|
53
|
+
rows.push(['delete-dir', managedRoot]);
|
|
54
|
+
for (const target of record.targets) rows.push(['install', target.agent, target.strategy]);
|
|
55
|
+
rows.push(['agents', record.targets.map(target => target.agent).join(',')]);
|
|
56
|
+
if (!rows.every(row => row.every(plain))) refuse('a path or name in the plan contains a control character (a line break, tab or similar)');
|
|
57
|
+
return rows;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Exact manual reinstall steps for a managed scope whose update was refused with migration_required.
|
|
62
|
+
* Targets are deleted first and the managed root (which holds the install record) last, so the steps can be repeated
|
|
63
|
+
* after a partial failure. `.optbrew/runs` is never deleted.
|
|
64
|
+
*/
|
|
65
|
+
export function reinstallGuidance({ record, recordRoot, from, to }) {
|
|
66
|
+
const separator = record.scope_id.indexOf(':');
|
|
67
|
+
const scope = record.scope_id.slice(0, separator), scopeRoot = record.scope_id.slice(separator + 1);
|
|
68
|
+
const agents = record.targets.map(target => target.agent).join(',');
|
|
69
|
+
const place = quote => scope === 'user' ? `--scope user --home ${quote(scopeRoot)}` : `--scope project --project ${quote(scopeRoot)}`;
|
|
70
|
+
const removePowershell = record.targets.map(target => target.strategy === 'link' ?
|
|
71
|
+
`[IO.Directory]::Delete(${powershell(target.path)})` : `Remove-Item -Recurse -Force -LiteralPath ${powershell(target.path)}`);
|
|
72
|
+
removePowershell.push(`Remove-Item -Recurse -Force -LiteralPath ${powershell(recordRoot)}`);
|
|
73
|
+
const removePosix = `rm -r -- ${[...record.targets.map(target => target.path), recordRoot].map(posix).join(' ')}`;
|
|
74
|
+
const install = quote => record.targets.map(target =>
|
|
75
|
+
`optbrew install --agents ${target.agent} ${place(quote)} --strategy ${target.strategy}`);
|
|
76
|
+
const doctor = quote => `optbrew doctor --agents ${agents} ${place(quote)}`;
|
|
77
|
+
return [
|
|
78
|
+
`Release ${to} uses a new evaluation contract, so a managed update from ${from} cannot carry this install over, and scores from the two releases are not comparable. Reinstall instead; your runs folder (${scopeRoot}/.optbrew/runs) is not touched, so never delete it.`,
|
|
79
|
+
'1. Check the install record first. This read-only command refuses a tampered record and prints exactly what will be deleted:',
|
|
80
|
+
` optbrew reinstall-plan ${place(posix)}`,
|
|
81
|
+
'2. Delete only those paths, the targets first and the managed root last (repeat the same steps if one fails):',
|
|
82
|
+
' PowerShell:',
|
|
83
|
+
...removePowershell.map(line => ` ${line}`),
|
|
84
|
+
` POSIX shell: ${removePosix}`,
|
|
85
|
+
'3. Install again, one command per recorded agent, from the checkout of the new release:',
|
|
86
|
+
...['PowerShell', 'POSIX shell'].flatMap((label, i) => [` ${label}:`, ...install(i ? posix : powershell).map(line => ` ${line}`)]),
|
|
87
|
+
'4. Verify:',
|
|
88
|
+
` PowerShell: ${doctor(powershell)}`,
|
|
89
|
+
` POSIX shell: ${doctor(posix)}`,
|
|
90
|
+
'The same steps as copy-paste blocks that do these checks for you: docs/platforms.md, "Reinstall after a migration_required update".',
|
|
91
|
+
].join('\n');
|
|
92
|
+
}
|