chati-dev 4.4.1 → 4.5.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/README.md +29 -27
- package/bin/chati.js +235 -1
- package/framework/agents/plan/tasks.md +46 -1
- package/framework/config.yaml +2 -2
- package/framework/constitution.md +13 -16
- package/framework/context/governance.md +5 -5
- package/framework/context/root.md +5 -5
- package/framework/i18n/en.yaml +8 -2
- package/framework/i18n/es.yaml +8 -2
- package/framework/i18n/fr.yaml +8 -2
- package/framework/i18n/pt.yaml +8 -2
- package/framework/manifest.json +21 -21
- package/framework/manifest.sig +1 -1
- package/framework/orchestrator/chati.md +43 -12
- package/node_modules/@chati/browser-capability/README.md +10 -0
- package/node_modules/@chati/browser-capability/package.json +17 -0
- package/node_modules/@chati/browser-capability/src/index.js +165 -0
- package/node_modules/@chati/core/package.json +13 -0
- package/node_modules/@chati/core/src/index.js +111 -0
- package/node_modules/@chati/knowledge-context/package.json +17 -0
- package/node_modules/@chati/knowledge-context/src/index.js +202 -0
- package/node_modules/@chati/planning/package.json +17 -0
- package/node_modules/@chati/planning/src/index.js +367 -0
- package/node_modules/@chati/provider-registry/package.json +16 -0
- package/node_modules/@chati/provider-registry/src/index.js +148 -0
- package/node_modules/@chati/rail/README.md +24 -0
- package/node_modules/@chati/rail/package.json +19 -0
- package/node_modules/@chati/rail/src/index.js +437 -0
- package/node_modules/@chati/release-lane/README.md +24 -0
- package/node_modules/@chati/release-lane/package.json +17 -0
- package/node_modules/@chati/release-lane/src/index.js +172 -0
- package/node_modules/@chati/review-council/package.json +17 -0
- package/node_modules/@chati/review-council/src/index.js +264 -0
- package/node_modules/@chati/tracking-clickup/README.md +55 -0
- package/node_modules/@chati/tracking-clickup/package.json +17 -0
- package/node_modules/@chati/tracking-clickup/src/index.js +293 -0
- package/package.json +25 -3
- package/src/config/ide-configs.js +11 -0
- package/src/context/domain-loader.js +1 -1
- package/src/dashboard/data-reader.js +1 -1
- package/src/executors/runner.js +1 -1
- package/src/installer/core.js +14 -13
- package/src/installer/provider-overlay.js +8 -15
- package/src/installer/scaffold-applier.js +1 -1
- package/src/installer/templates.js +12 -25
- package/src/installer/validator.js +5 -10
- package/src/installer-v2/catalog-client.js +165 -0
- package/src/installer-v2/index.js +304 -0
- package/src/installer-v2/model-catalog-envelope.json +278 -0
- package/src/installer-v2/model-catalog.json +130 -0
- package/src/installer-v2/model-catalog.sig +1 -0
- package/src/installer-v2/wizard-installation.js +116 -0
- package/src/intelligence/registry-manager.js +1 -1
- package/src/license/client.js +27 -1
- package/src/memory/session-digest.js +1 -1
- package/src/merger/yaml-merger.js +1 -1
- package/src/orchestrator/browser-runtime.js +25 -0
- package/src/orchestrator/cli.js +93 -8
- package/src/orchestrator/clickup-projection.js +84 -0
- package/src/orchestrator/clickup-runtime.js +13 -0
- package/src/orchestrator/knowledge-runtime.js +64 -0
- package/src/orchestrator/planning-runtime.js +127 -0
- package/src/orchestrator/rail-runtime.js +421 -0
- package/src/orchestrator/release-runtime.js +14 -0
- package/src/orchestrator/review-runtime.js +74 -0
- package/src/orchestrator/runtime-installation-v2.js +57 -0
- package/src/orchestrator/session-manager.js +1 -1
- package/src/telemetry/config.js +1 -1
- package/src/terminal/adapters/grok-adapter.js +16 -0
- package/src/terminal/adapters/index.js +1 -0
- package/src/terminal/cli-registry.js +20 -5
- package/src/terminal/prompt-builder.js +4 -2
- package/src/terminal/run-agent.js +3 -0
- package/src/terminal/run-parallel.js +21 -10
- package/src/terminal/spawner.js +7 -1
- package/src/terminal/team-task-list.js +1 -1
- package/src/upgrade/checker.js +1 -1
- package/src/upgrade/migrator.js +1 -1
- package/src/utils/config-parser.js +3 -8
- package/src/wizard/i18n.js +10 -4
- package/src/wizard/index.js +49 -17
- package/src/wizard/questions.js +50 -28
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'fs';
|
|
2
2
|
import { join } from 'path';
|
|
3
|
-
import yaml from 'js-yaml';
|
|
3
|
+
import * as yaml from 'js-yaml';
|
|
4
4
|
import { hashFile } from './file-hasher.js';
|
|
5
5
|
import { loadManifest } from './manifest.js';
|
|
6
6
|
import { ADAPTABLE_FILES } from '../config/framework-adapter.js';
|
|
@@ -187,28 +187,23 @@ export async function validateInstallation(targetDir) {
|
|
|
187
187
|
try {
|
|
188
188
|
const configContent = readFileSync(configYamlPath, 'utf-8');
|
|
189
189
|
const enabledProviders = [];
|
|
190
|
-
let primaryProvider = null;
|
|
191
190
|
let currentProvider = null;
|
|
192
191
|
|
|
193
192
|
for (const line of configContent.split('\n')) {
|
|
194
193
|
const provLine = line.match(/^\s{4}(\w+):$/);
|
|
195
|
-
if (provLine && ['claude', 'gemini', 'codex'].includes(provLine[1])) {
|
|
194
|
+
if (provLine && ['claude', 'gemini', 'codex', 'grok'].includes(provLine[1])) {
|
|
196
195
|
currentProvider = provLine[1];
|
|
197
196
|
}
|
|
198
197
|
if (currentProvider && line.includes('enabled: true')) {
|
|
199
198
|
enabledProviders.push(currentProvider);
|
|
200
199
|
}
|
|
201
|
-
if (currentProvider && line.includes('primary: true')) {
|
|
202
|
-
primaryProvider = currentProvider;
|
|
203
|
-
}
|
|
204
200
|
}
|
|
205
201
|
|
|
206
|
-
// Validate overlay completeness for
|
|
207
|
-
if (enabledProviders.length >
|
|
208
|
-
const secondaryProviders = enabledProviders.filter(p => p !== primaryProvider);
|
|
202
|
+
// Validate overlay completeness for every enabled provider.
|
|
203
|
+
if (enabledProviders.length > 0) {
|
|
209
204
|
const overlayDetails = [];
|
|
210
205
|
|
|
211
|
-
for (const provider of
|
|
206
|
+
for (const provider of enabledProviders) {
|
|
212
207
|
let present = 0;
|
|
213
208
|
let missing = 0;
|
|
214
209
|
const missingFiles = [];
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { createPublicKey, verify as cryptoVerify } from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { isIP } from 'node:net';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { canonicalSerialize, ContractError } from '@chati/core';
|
|
7
|
+
import { validateCapabilitySnapshot } from '@chati/provider-registry';
|
|
8
|
+
|
|
9
|
+
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_CATALOG_URL = 'https://raw.githubusercontent.com/Chati-dev/Chati.dev/main/packages/chati-dev/src/installer-v2/model-catalog-envelope.json';
|
|
12
|
+
export const CATALOG_CACHE_PATH = '.chati/v2/cache/model-catalog.json';
|
|
13
|
+
export const BUNDLED_CATALOG_PATH = join(moduleDir, 'model-catalog.json');
|
|
14
|
+
export const BUNDLED_CATALOG_SIGNATURE_PATH = join(moduleDir, 'model-catalog.sig');
|
|
15
|
+
const PUBLIC_KEY_PATH = join(moduleDir, '..', 'installer', 'signing-public-key.pem');
|
|
16
|
+
|
|
17
|
+
function catalogError(code, message, details = {}) {
|
|
18
|
+
return new ContractError(code, message, details);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function parseSignedPayload(value) {
|
|
22
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
23
|
+
throw catalogError('INVALID_SIGNED_CATALOG', 'signed catalog payload must be an object');
|
|
24
|
+
}
|
|
25
|
+
if (!value.catalog || typeof value.signature !== 'string' || !value.signature.trim()) {
|
|
26
|
+
throw catalogError('INVALID_SIGNED_CATALOG', 'signed catalog payload requires catalog and signature');
|
|
27
|
+
}
|
|
28
|
+
return { catalog: value.catalog, signature: value.signature.trim() };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readPublicKey(publicKeyPem) {
|
|
32
|
+
const source = publicKeyPem ?? readFileSync(PUBLIC_KEY_PATH, 'utf8');
|
|
33
|
+
return createPublicKey(source);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function assertCatalogUrl(value) {
|
|
37
|
+
let url;
|
|
38
|
+
try { url = new URL(value); } catch { throw catalogError('CATALOG_URL_INVALID', 'catalog URL must be an absolute HTTPS URL'); }
|
|
39
|
+
if (url.protocol !== 'https:' || url.username || url.password) throw catalogError('CATALOG_URL_INVALID', 'catalog URL must use HTTPS without embedded credentials');
|
|
40
|
+
const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
41
|
+
const ipKind = isIP(hostname);
|
|
42
|
+
const blockedIpv4 = ipKind === 4 && (/^10\./.test(hostname)
|
|
43
|
+
|| /^127\./.test(hostname)
|
|
44
|
+
|| /^169\.254\./.test(hostname)
|
|
45
|
+
|| /^192\.168\./.test(hostname)
|
|
46
|
+
|| /^172\.(1[6-9]|2\d|3[01])\./.test(hostname)
|
|
47
|
+
|| /^0\./.test(hostname));
|
|
48
|
+
const blockedIpv6 = ipKind === 6 && (hostname === '::1' || hostname === '::' || /^(fc|fd|fe8|fe9|fea|feb)/.test(hostname));
|
|
49
|
+
if (hostname === 'localhost' || hostname.endsWith('.localhost') || blockedIpv4 || blockedIpv6) {
|
|
50
|
+
throw catalogError('CATALOG_URL_FORBIDDEN', 'catalog URL may not target a loopback, link-local, or private host');
|
|
51
|
+
}
|
|
52
|
+
return url.toString();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function verifySignedCapabilityCatalog(payload, { clock = () => new Date(), publicKeyPem } = {}) {
|
|
56
|
+
const { catalog, signature } = parseSignedPayload(payload);
|
|
57
|
+
const verified = cryptoVerify(
|
|
58
|
+
null,
|
|
59
|
+
Buffer.from(canonicalSerialize(catalog)),
|
|
60
|
+
readPublicKey(publicKeyPem),
|
|
61
|
+
Buffer.from(signature, 'base64'),
|
|
62
|
+
);
|
|
63
|
+
if (!verified) throw catalogError('CATALOG_SIGNATURE_INVALID', 'model catalog signature is invalid');
|
|
64
|
+
return validateCapabilitySnapshot(catalog, { clock });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function readSignedCatalogFile(path) {
|
|
68
|
+
return parseSignedPayload(JSON.parse(readFileSync(path, 'utf8')));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function writeCache(projectDir, payload) {
|
|
72
|
+
const path = join(projectDir, CATALOG_CACHE_PATH);
|
|
73
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
74
|
+
const temporary = `${path}.tmp-${process.pid}`;
|
|
75
|
+
writeFileSync(temporary, `${JSON.stringify(payload, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' });
|
|
76
|
+
renameSync(temporary, path);
|
|
77
|
+
return CATALOG_CACHE_PATH;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function readBundledPayload() {
|
|
81
|
+
return {
|
|
82
|
+
catalog: JSON.parse(readFileSync(BUNDLED_CATALOG_PATH, 'utf8')),
|
|
83
|
+
signature: readFileSync(BUNDLED_CATALOG_SIGNATURE_PATH, 'utf8').trim(),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function catalogPublishedAt(catalog) {
|
|
88
|
+
const parsed = catalog.published_at ? Date.parse(catalog.published_at) : 0;
|
|
89
|
+
return Number.isNaN(parsed) ? 0 : parsed;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const SOURCE_PRIORITY = Object.freeze({ remote: 3, cache: 2, bundled: 1 });
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Resolves every available signed CHATI model catalog and selects the newest
|
|
96
|
+
* published snapshot. Remote wins ties, then cache, then the bundled release.
|
|
97
|
+
* Vendor APIs and local binary inference stay outside this trust boundary.
|
|
98
|
+
*/
|
|
99
|
+
export async function resolveCapabilityCatalog({
|
|
100
|
+
projectDir,
|
|
101
|
+
catalogUrl = process.env.CHATI_MODEL_CATALOG_URL || DEFAULT_CATALOG_URL,
|
|
102
|
+
fetchImpl = globalThis.fetch,
|
|
103
|
+
clock = () => new Date(),
|
|
104
|
+
publicKeyPem,
|
|
105
|
+
timeoutMs = 5000,
|
|
106
|
+
} = {}) {
|
|
107
|
+
const failures = [];
|
|
108
|
+
const candidates = [];
|
|
109
|
+
|
|
110
|
+
if (catalogUrl && typeof fetchImpl === 'function') {
|
|
111
|
+
try {
|
|
112
|
+
const safeCatalogUrl = assertCatalogUrl(catalogUrl);
|
|
113
|
+
const response = await fetchImpl(safeCatalogUrl, {
|
|
114
|
+
headers: { accept: 'application/json' },
|
|
115
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
116
|
+
});
|
|
117
|
+
if (!response.ok) throw catalogError('CATALOG_HTTP_ERROR', `catalog endpoint returned HTTP ${response.status}`);
|
|
118
|
+
const payload = parseSignedPayload(await response.json());
|
|
119
|
+
const catalog = verifySignedCapabilityCatalog(payload, { clock, publicKeyPem });
|
|
120
|
+
candidates.push({ source: 'remote', source_url: safeCatalogUrl, payload, catalog });
|
|
121
|
+
} catch (error) {
|
|
122
|
+
failures.push({ source: 'remote', code: error.code || error.name || 'CATALOG_FETCH_FAILED' });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (projectDir) {
|
|
127
|
+
const cachePath = join(projectDir, CATALOG_CACHE_PATH);
|
|
128
|
+
if (existsSync(cachePath)) {
|
|
129
|
+
try {
|
|
130
|
+
const payload = readSignedCatalogFile(cachePath);
|
|
131
|
+
const catalog = verifySignedCapabilityCatalog(payload, { clock, publicKeyPem });
|
|
132
|
+
candidates.push({ source: 'cache', cache_path: CATALOG_CACHE_PATH, payload, catalog });
|
|
133
|
+
} catch (error) {
|
|
134
|
+
failures.push({ source: 'cache', code: error.code || error.name || 'CATALOG_CACHE_INVALID' });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const payload = readBundledPayload();
|
|
141
|
+
const catalog = verifySignedCapabilityCatalog(payload, { clock, publicKeyPem });
|
|
142
|
+
candidates.push({ source: 'bundled', cache_path: null, payload, catalog });
|
|
143
|
+
} catch (error) {
|
|
144
|
+
failures.push({ source: 'bundled', code: error.code || error.name || 'CATALOG_BUNDLE_INVALID' });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (candidates.length === 0) {
|
|
148
|
+
throw catalogError('CATALOG_UNAVAILABLE', 'no fresh, signed CHATI model catalog is available', { failures });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
candidates.sort((left, right) =>
|
|
152
|
+
catalogPublishedAt(right.catalog) - catalogPublishedAt(left.catalog)
|
|
153
|
+
|| SOURCE_PRIORITY[right.source] - SOURCE_PRIORITY[left.source]
|
|
154
|
+
);
|
|
155
|
+
const selected = candidates[0];
|
|
156
|
+
const cache_path = selected.source === 'remote' && projectDir
|
|
157
|
+
? writeCache(projectDir, selected.payload)
|
|
158
|
+
: (selected.cache_path ?? null);
|
|
159
|
+
return Object.freeze({
|
|
160
|
+
source: selected.source,
|
|
161
|
+
...(selected.source_url ? { source_url: selected.source_url } : {}),
|
|
162
|
+
cache_path,
|
|
163
|
+
catalog: selected.catalog,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Installer v2 is deliberately a planner, not an installer.
|
|
3
|
+
*
|
|
4
|
+
* It creates one provider-neutral installation artifact, inspects a target for
|
|
5
|
+
* legacy v1 markers, and returns a migration/rollback plan. Artifact writes
|
|
6
|
+
* are explicit through installV2 or authorized migrateV2. It never invokes a
|
|
7
|
+
* provider CLI or reads credentials.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, mkdirSync, writeFileSync, renameSync, readFileSync, copyFileSync } from 'node:fs';
|
|
10
|
+
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
11
|
+
import { canonicalize, canonicalSerialize, sha256, ContractError } from '@chati/core';
|
|
12
|
+
import { validateInstallation } from '@chati/provider-registry';
|
|
13
|
+
|
|
14
|
+
export const INSTALLATION_SCHEMA_VERSION = 2;
|
|
15
|
+
export const INSTALLATION_ARTIFACT_PATH = '.chati/v2/installation.json';
|
|
16
|
+
|
|
17
|
+
const V1_MARKERS = Object.freeze([
|
|
18
|
+
{ provider_id: 'anthropic', harness_id: 'claude', path: '.claude/settings.json' },
|
|
19
|
+
{ provider_id: 'anthropic', harness_id: 'claude', path: 'CLAUDE.local.md' },
|
|
20
|
+
{ provider_id: 'openai', harness_id: 'codex', path: '.codex/config.toml' },
|
|
21
|
+
{ provider_id: 'xai', harness_id: 'grok', path: '.grok/config.json' },
|
|
22
|
+
{ provider_id: null, harness_id: null, path: '.chati/session.yaml' },
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
const PROVIDER_SPECIFIC_ROOT_FILES = new Set([
|
|
26
|
+
'claude.md', 'claude.local.md', 'codex.md', 'codex.local.md', 'grok.md', 'grok.local.md',
|
|
27
|
+
'.claude.lock', '.codex.lock', '.grok.lock',
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
function assertObject(value, code, label) {
|
|
31
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new ContractError(code, `${label} must be an object`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function assertString(value, code, label) {
|
|
35
|
+
if (typeof value !== 'string' || value.trim() === '') throw new ContractError(code, `${label} must be a non-empty string`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function readClock(clock) {
|
|
39
|
+
if (typeof clock !== 'function') throw new ContractError('MISSING_CLOCK', 'a deterministic clock function is required');
|
|
40
|
+
const result = clock();
|
|
41
|
+
const date = result instanceof Date ? result : new Date(result);
|
|
42
|
+
if (Number.isNaN(date.getTime())) throw new ContractError('INVALID_CLOCK', 'clock must return a valid timestamp');
|
|
43
|
+
return date.toISOString();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function ensureRelativePath(path) {
|
|
47
|
+
assertString(path, 'INVALID_MANAGED_PATH', 'managed path');
|
|
48
|
+
const normalized = path.replaceAll('\\', '/');
|
|
49
|
+
const root = normalized.split('/')[0];
|
|
50
|
+
if (normalized.startsWith('/') || normalized.split('/').includes('..')) {
|
|
51
|
+
throw new ContractError('INVALID_MANAGED_PATH', 'managed path must remain inside the project');
|
|
52
|
+
}
|
|
53
|
+
if (['.claude', '.codex', '.grok'].includes(root)) {
|
|
54
|
+
throw new ContractError('PROVIDER_SPECIFIC_MANAGED_PATH', 'installer v2 may not manage provider-specific configuration paths');
|
|
55
|
+
}
|
|
56
|
+
if (PROVIDER_SPECIFIC_ROOT_FILES.has(root.toLowerCase())) {
|
|
57
|
+
throw new ContractError('PROVIDER_SPECIFIC_MANAGED_PATH', 'installer v2 may not manage provider-specific root markers or locks');
|
|
58
|
+
}
|
|
59
|
+
return normalized;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function normalizeBrainReadCapability(capability) {
|
|
63
|
+
if (capability === undefined || capability === null || capability === false) return { status: 'not-configured' };
|
|
64
|
+
assertObject(capability, 'INVALID_BRAIN_CAPABILITY', 'brain_read_capability');
|
|
65
|
+
if (capability.status === 'not-configured') return { status: 'not-configured' };
|
|
66
|
+
if (capability.mode !== 'read-only') throw new ContractError('BRAIN_WRITE_FORBIDDEN', 'installer v2 permits only a read-only Brain capability');
|
|
67
|
+
assertString(capability.policy_ref, 'INVALID_BRAIN_CAPABILITY', 'brain_read_capability.policy_ref');
|
|
68
|
+
return { status: 'configured', mode: 'read-only', policy_ref: capability.policy_ref };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function artifactCore(input, createdAt) {
|
|
72
|
+
assertObject(input, 'INVALID_INSTALLATION', 'installation input');
|
|
73
|
+
assertObject(input.installation, 'INVALID_INSTALLATION', 'installation');
|
|
74
|
+
assertObject(input.capability_snapshot, 'INVALID_CATALOG', 'capability_snapshot');
|
|
75
|
+
assertString(input.project_id, 'INVALID_INSTALLATION', 'project_id');
|
|
76
|
+
const installation = validateInstallation(input.installation, input.capability_snapshot, { clock: () => createdAt });
|
|
77
|
+
const brain_read_capability = normalizeBrainReadCapability(input.brain_read_capability);
|
|
78
|
+
const managed_paths = (input.managed_paths ?? [INSTALLATION_ARTIFACT_PATH]).map(ensureRelativePath);
|
|
79
|
+
if (!managed_paths.includes(INSTALLATION_ARTIFACT_PATH)) managed_paths.unshift(INSTALLATION_ARTIFACT_PATH);
|
|
80
|
+
if (new Set(managed_paths).size !== managed_paths.length) throw new ContractError('DUPLICATE_MANAGED_PATH', 'managed_paths must be unique');
|
|
81
|
+
const selectedModelKeys = new Set(installation.enabled_providers.flatMap((binding) =>
|
|
82
|
+
binding.allowed_models.map((model_id) => `${binding.provider_id}:${model_id}`),
|
|
83
|
+
));
|
|
84
|
+
const capability_snapshot = {
|
|
85
|
+
// This is a projection, not a catalog copy. Keep only attributes needed by
|
|
86
|
+
// the installation contract and only models explicitly selected by a binding.
|
|
87
|
+
snapshot_id: input.capability_snapshot.snapshot_id,
|
|
88
|
+
expires_at: input.capability_snapshot.expires_at,
|
|
89
|
+
models: input.capability_snapshot.models
|
|
90
|
+
.filter((model) => selectedModelKeys.has(`${model.provider_id}:${model.model_id}`))
|
|
91
|
+
.map(({ provider_id, model_id, actions, adjudication_priority, highest_reasoning_configuration, routing_priority, tier, natural_role }) => ({
|
|
92
|
+
provider_id,
|
|
93
|
+
model_id,
|
|
94
|
+
actions,
|
|
95
|
+
...(adjudication_priority !== undefined ? { adjudication_priority } : {}),
|
|
96
|
+
...(highest_reasoning_configuration !== undefined ? { highest_reasoning_configuration } : {}),
|
|
97
|
+
...(routing_priority !== undefined ? { routing_priority } : {}),
|
|
98
|
+
...(tier !== undefined ? { tier } : {}),
|
|
99
|
+
...(natural_role !== undefined ? { natural_role } : {}),
|
|
100
|
+
})),
|
|
101
|
+
};
|
|
102
|
+
return {
|
|
103
|
+
schema_version: INSTALLATION_SCHEMA_VERSION,
|
|
104
|
+
artifact_type: 'chati-installation',
|
|
105
|
+
project_id: input.project_id,
|
|
106
|
+
created_at: createdAt,
|
|
107
|
+
installation,
|
|
108
|
+
// An artifact exposes no capability information for providers not selected
|
|
109
|
+
// during installation. The full catalog remains an input, never an output.
|
|
110
|
+
capability_snapshot,
|
|
111
|
+
brain_read_capability,
|
|
112
|
+
managed_paths,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Creates a deterministic provider-neutral artifact. It never installs it. */
|
|
117
|
+
export function createInstallationArtifact(input, { clock = () => new Date(), artifactId } = {}) {
|
|
118
|
+
const core = artifactCore(input, readClock(clock));
|
|
119
|
+
const digest = sha256(core);
|
|
120
|
+
const installation_artifact_id = artifactId ?? `install-v2-${digest.slice(0, 16)}`;
|
|
121
|
+
assertString(installation_artifact_id, 'INVALID_INSTALLATION', 'installation_artifact_id');
|
|
122
|
+
return Object.freeze(canonicalize({ ...core, installation_artifact_id, digest }));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Validates an artifact without inspecting provider-specific configuration files. */
|
|
126
|
+
export function doctorV2(artifact, { clock = () => new Date() } = {}) {
|
|
127
|
+
const checks = [];
|
|
128
|
+
try {
|
|
129
|
+
assertObject(artifact, 'INVALID_ARTIFACT', 'artifact');
|
|
130
|
+
if (artifact.schema_version !== INSTALLATION_SCHEMA_VERSION || artifact.artifact_type !== 'chati-installation') {
|
|
131
|
+
throw new ContractError('UNSUPPORTED_INSTALLATION_ARTIFACT', 'expected a chati installation artifact v2');
|
|
132
|
+
}
|
|
133
|
+
const { digest, installation_artifact_id, ...core } = artifact;
|
|
134
|
+
assertString(installation_artifact_id, 'INVALID_ARTIFACT', 'installation_artifact_id');
|
|
135
|
+
if (sha256(core) !== digest) throw new ContractError('INSTALLATION_ARTIFACT_DRIFT', 'installation artifact digest does not match its content');
|
|
136
|
+
validateInstallation(artifact.installation, artifact.capability_snapshot, { clock: () => readClock(clock) });
|
|
137
|
+
normalizeBrainReadCapability(artifact.brain_read_capability);
|
|
138
|
+
if (!Array.isArray(artifact.managed_paths) || !artifact.managed_paths.every((path) => ensureRelativePath(path))) {
|
|
139
|
+
throw new ContractError('INVALID_MANAGED_PATH', 'managed_paths must be valid project-relative paths');
|
|
140
|
+
}
|
|
141
|
+
checks.push({ name: 'artifact', status: 'pass', message: 'artifact schema and digest are valid' });
|
|
142
|
+
checks.push({ name: 'selected-bindings', status: 'pass', message: 'only explicitly selected provider/harness bindings are present' });
|
|
143
|
+
checks.push({ name: 'brain-capability', status: 'pass', message: 'Brain capability is absent or read-only' });
|
|
144
|
+
} catch (error) {
|
|
145
|
+
checks.push({ name: 'artifact', status: 'fail', code: error.code ?? 'INVALID_ARTIFACT', message: error.message });
|
|
146
|
+
}
|
|
147
|
+
return Object.freeze({ schema_version: 1, doctor: 'chati-installation-v2', passed: checks.every((check) => check.status === 'pass'), checks });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Read-only detector for known v1 footprints. Marker presence never enables a provider. */
|
|
151
|
+
export function detectV1Installation(projectDir, { exists = existsSync } = {}) {
|
|
152
|
+
assertString(projectDir, 'INVALID_PROJECT_DIR', 'projectDir');
|
|
153
|
+
if (typeof exists !== 'function') throw new ContractError('INVALID_DETECTOR', 'exists must be a function');
|
|
154
|
+
const markers = V1_MARKERS.filter((marker) => exists(join(projectDir, marker.path))).map((marker) => ({ ...marker }));
|
|
155
|
+
const bindings = markers
|
|
156
|
+
.filter((marker) => marker.provider_id)
|
|
157
|
+
.map(({ provider_id, harness_id }) => ({ provider_id, harness_id }))
|
|
158
|
+
.filter((binding, index, all) => all.findIndex((entry) => entry.provider_id === binding.provider_id && entry.harness_id === binding.harness_id) === index);
|
|
159
|
+
return Object.freeze({ detector_version: 1, detected: markers.length > 0, markers, detected_bindings: bindings });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Builds a backup plan only. The caller must separately authorize and execute any write. */
|
|
163
|
+
export function createBackupPlan(projectDir, detector, artifact) {
|
|
164
|
+
assertString(projectDir, 'INVALID_PROJECT_DIR', 'projectDir');
|
|
165
|
+
assertObject(detector, 'INVALID_DETECTOR', 'detector');
|
|
166
|
+
assertObject(artifact, 'INVALID_ARTIFACT', 'artifact');
|
|
167
|
+
const backup_id = `backup-v2-${artifact.digest.slice(0, 16)}`;
|
|
168
|
+
const entries = detector.markers.map((marker) => ({ source_path: marker.path, backup_path: `.chati/v2/backups/${backup_id}/${marker.path}` }));
|
|
169
|
+
return Object.freeze(canonicalize({ schema_version: 1, backup_id, project_dir: resolve(projectDir), entries, write_required: false }));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Produces the inverse of a future authorized migration, without performing either operation. */
|
|
173
|
+
export function createRollbackPlan(artifact, backupPlan) {
|
|
174
|
+
assertObject(artifact, 'INVALID_ARTIFACT', 'artifact');
|
|
175
|
+
assertObject(backupPlan, 'INVALID_BACKUP_PLAN', 'backupPlan');
|
|
176
|
+
return Object.freeze(canonicalize({
|
|
177
|
+
schema_version: 1,
|
|
178
|
+
rollback_id: `rollback-v2-${artifact.digest.slice(0, 16)}`,
|
|
179
|
+
installation_artifact_id: artifact.installation_artifact_id,
|
|
180
|
+
backup_id: backupPlan.backup_id,
|
|
181
|
+
restore_entries: backupPlan.entries,
|
|
182
|
+
remove_created_paths: artifact.managed_paths,
|
|
183
|
+
requires_explicit_authorization: true,
|
|
184
|
+
write_required: false,
|
|
185
|
+
}));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Read-only migration preview. It does not write a v2 artifact or back up v1.
|
|
190
|
+
* Re-running with the same inputs returns the same plans and does not duplicate
|
|
191
|
+
* provider bindings or managed paths.
|
|
192
|
+
*/
|
|
193
|
+
export function dryRunV2(input, { projectDir, clock = () => new Date(), exists = existsSync } = {}) {
|
|
194
|
+
assertString(projectDir, 'INVALID_PROJECT_DIR', 'projectDir');
|
|
195
|
+
const artifact = createInstallationArtifact(input, { clock });
|
|
196
|
+
const doctor = doctorV2(artifact, { clock });
|
|
197
|
+
const detector = detectV1Installation(projectDir, { exists });
|
|
198
|
+
const backup_plan = createBackupPlan(projectDir, detector, artifact);
|
|
199
|
+
const rollback_plan = createRollbackPlan(artifact, backup_plan);
|
|
200
|
+
const configured = new Set(artifact.installation.enabled_providers.map(({ provider_id, harness_id }) => `${provider_id}:${harness_id}`));
|
|
201
|
+
const incompatibilities = detector.detected_bindings
|
|
202
|
+
.filter(({ provider_id, harness_id }) => !configured.has(`${provider_id}:${harness_id}`))
|
|
203
|
+
.map(({ provider_id, harness_id }) => ({ code: 'V1_BINDING_NOT_SELECTED', provider_id, harness_id, message: 'legacy marker is not a selected v2 binding' }));
|
|
204
|
+
const pathOutsideProject = artifact.managed_paths.some((managedPath) => relative(resolve(projectDir), resolve(projectDir, managedPath)).split(sep).includes('..'));
|
|
205
|
+
if (pathOutsideProject) throw new ContractError('INVALID_MANAGED_PATH', 'managed path resolves outside project');
|
|
206
|
+
return Object.freeze(canonicalize({ schema_version: 1, mode: 'dry-run', writes_performed: false, artifact, doctor, detector, backup_plan, rollback_plan, incompatibilities }));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Explicit v2 installation. It writes only the provider-neutral artifact path. */
|
|
210
|
+
export function installV2(input, { projectDir, clock = () => new Date(), exists = existsSync } = {}) {
|
|
211
|
+
const report = dryRunV2(input, { projectDir, clock, exists });
|
|
212
|
+
const artifactPath = join(projectDir, INSTALLATION_ARTIFACT_PATH);
|
|
213
|
+
const directory = join(projectDir, '.chati', 'v2');
|
|
214
|
+
mkdirSync(directory, { recursive: true });
|
|
215
|
+
const serialized = `${canonicalSerialize(report.artifact)}\n`;
|
|
216
|
+
if (existsSync(artifactPath)) {
|
|
217
|
+
const existing = readFileSync(artifactPath, 'utf8');
|
|
218
|
+
if (existing === serialized) return Object.freeze(canonicalize({ ...report, mode: 'installed', writes_performed: false, artifact_path: INSTALLATION_ARTIFACT_PATH }));
|
|
219
|
+
throw new ContractError('INSTALLATION_ARTIFACT_EXISTS', 'existing v2 artifact differs; use an explicit migration or rollback flow');
|
|
220
|
+
}
|
|
221
|
+
const temporary = `${artifactPath}.tmp-${process.pid}`;
|
|
222
|
+
writeFileSync(temporary, serialized, { encoding: 'utf8', flag: 'wx' });
|
|
223
|
+
renameSync(temporary, artifactPath);
|
|
224
|
+
return Object.freeze(canonicalize({ ...report, mode: 'installed', writes_performed: true, artifact_path: INSTALLATION_ARTIFACT_PATH }));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Explicitly replaces an existing v2 artifact after preserving its exact
|
|
229
|
+
* bytes in the project-local backup store. Provider-specific files are never
|
|
230
|
+
* read or rewritten by this operation.
|
|
231
|
+
*/
|
|
232
|
+
export function reconfigureV2(input, { projectDir, clock = () => new Date(), exists = existsSync } = {}) {
|
|
233
|
+
const report = dryRunV2(input, { projectDir, clock, exists });
|
|
234
|
+
const artifactPath = join(projectDir, INSTALLATION_ARTIFACT_PATH);
|
|
235
|
+
if (!existsSync(artifactPath)) return installV2(input, { projectDir, clock, exists });
|
|
236
|
+
const existing = readFileSync(artifactPath, 'utf8');
|
|
237
|
+
let existingArtifact;
|
|
238
|
+
try { existingArtifact = JSON.parse(existing); } catch (error) {
|
|
239
|
+
throw new ContractError('EXISTING_INSTALLATION_CORRUPT', `existing v2 artifact is not valid JSON: ${error.message}`);
|
|
240
|
+
}
|
|
241
|
+
if (existingArtifact?.schema_version !== INSTALLATION_SCHEMA_VERSION || existingArtifact?.artifact_type !== 'chati-installation') {
|
|
242
|
+
throw new ContractError('EXISTING_INSTALLATION_CORRUPT', 'existing v2 artifact has an unsupported schema');
|
|
243
|
+
}
|
|
244
|
+
const backupId = `reconfigure-${sha256({ previous: existingArtifact.digest, next: report.artifact.digest }).slice(0, 20)}`;
|
|
245
|
+
const backupPath = join(projectDir, '.chati', 'v2', 'backups', backupId, 'installation.json');
|
|
246
|
+
mkdirSync(dirname(backupPath), { recursive: true });
|
|
247
|
+
if (existsSync(backupPath) && readFileSync(backupPath, 'utf8') !== existing) {
|
|
248
|
+
throw new ContractError('RECONFIGURE_BACKUP_CONFLICT', 'reconfiguration backup path contains different material');
|
|
249
|
+
}
|
|
250
|
+
if (!existsSync(backupPath)) writeFileSync(backupPath, existing, { encoding: 'utf8', flag: 'wx', mode: 0o600 });
|
|
251
|
+
const serialized = `${canonicalSerialize(report.artifact)}\n`;
|
|
252
|
+
const temporary = `${artifactPath}.tmp-${process.pid}`;
|
|
253
|
+
writeFileSync(temporary, serialized, { encoding: 'utf8', flag: 'wx', mode: 0o600 });
|
|
254
|
+
renameSync(temporary, artifactPath);
|
|
255
|
+
return Object.freeze(canonicalize({
|
|
256
|
+
...report,
|
|
257
|
+
mode: 'reconfigured',
|
|
258
|
+
writes_performed: true,
|
|
259
|
+
artifact_path: INSTALLATION_ARTIFACT_PATH,
|
|
260
|
+
backup_path: relative(projectDir, backupPath).replaceAll('\\', '/'),
|
|
261
|
+
provider_specific_files_rewritten: false,
|
|
262
|
+
}));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Performs the narrow, explicit v4 to v2 activation. It backs up detected
|
|
267
|
+
* legacy markers, writes the provider-neutral artifact, and never rewrites a
|
|
268
|
+
* provider-specific configuration file.
|
|
269
|
+
*/
|
|
270
|
+
export function migrateV2(input, { projectDir, authorize = false, clock = () => new Date(), exists = existsSync } = {}) {
|
|
271
|
+
const preview = dryRunV2(input, { projectDir, clock, exists });
|
|
272
|
+
if (authorize !== true) {
|
|
273
|
+
return Object.freeze(canonicalize({ ...preview, mode: 'migration-preview', writes_performed: false, requires_explicit_authorization: true }));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const artifactPath = join(projectDir, INSTALLATION_ARTIFACT_PATH);
|
|
277
|
+
if (existsSync(artifactPath)) {
|
|
278
|
+
const expected = `${canonicalSerialize(preview.artifact)}\n`;
|
|
279
|
+
if (readFileSync(artifactPath, 'utf8') !== expected) {
|
|
280
|
+
throw new ContractError('INSTALLATION_ARTIFACT_EXISTS', 'existing v2 artifact differs; use an explicit rollback flow');
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const backed_up = [];
|
|
285
|
+
for (const entry of preview.backup_plan.entries) {
|
|
286
|
+
const source = join(projectDir, entry.source_path);
|
|
287
|
+
const destination = join(projectDir, entry.backup_path);
|
|
288
|
+
if (!existsSync(source) || existsSync(destination)) continue;
|
|
289
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
290
|
+
copyFileSync(source, destination, 0);
|
|
291
|
+
backed_up.push(entry);
|
|
292
|
+
}
|
|
293
|
+
const installed = installV2(input, { projectDir, clock, exists });
|
|
294
|
+
return Object.freeze(canonicalize({
|
|
295
|
+
...preview,
|
|
296
|
+
mode: 'migrated',
|
|
297
|
+
writes_performed: installed.writes_performed || backed_up.length > 0,
|
|
298
|
+
backed_up,
|
|
299
|
+
artifact_path: installed.artifact_path,
|
|
300
|
+
provider_specific_files_rewritten: false,
|
|
301
|
+
}));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export { canonicalSerialize };
|