@tera-system/pro 0.1.1 → 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/MANIFEST.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tera-system/pro",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Build manifest for the Tera System Pro commercial plugin component.",
5
5
  "compatibleEnvironments": [
6
6
  "opencode"
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tera-system/pro",
3
- "version": "0.1.1",
4
- "description": "Tera System Pro commercial edition with enforced license gate, server sync, and heartbeat verification",
3
+ "version": "0.1.2",
4
+ "description": "Tera System Pro — commercial edition with enforced license gate, server sync, and heartbeat verification",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "type": "module",
7
7
  "engines": {
@@ -1,406 +1,416 @@
1
- #!/usr/bin/env node
2
- /**
3
- * @tera/core installer
4
- * --------------------------------------------------------------
5
- * NON-DESTRUCTIVE + OWNERSHIP-AWARE installer for the Tera System
6
- * OpenCode Plugin component.
7
- *
8
- * Rules:
9
- * - Never deletes anything.
10
- * - Never overwrites a file it does not own.
11
- * - Tracks ownership via `.opencode/tera-core.manifest.json`.
12
- * - Only updates files it owns, always keeping a backup first.
13
- * - Idempotent: re-running is safe; identical files are skipped.
14
- * - Never touches an existing user `opencode.json`.
15
- *
16
- * Target resolution (first match wins):
17
- * 1. `--target <dir>` argument
18
- * 2. `TERA_TARGET_DIR` environment variable
19
- * 3. `INIT_CWD` (set by npm for lifecycle scripts)
20
- * 4. current working directory
21
- */
22
- import { createHash } from "node:crypto";
23
- import fs from "node:fs";
24
- import path from "node:path";
25
- import { fileURLToPath } from "node:url";
26
- import { assessLicenseState, readLicenseState, writeLicenseState } from "./lib/license.mjs";
27
- import { createIntegrityManifest, saveIntegrityManifest } from "./lib/integrity.mjs";
28
- import { verifyBundle, applyBundle } from "./lib/bundle.mjs";
29
-
30
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
31
- const PKG_ROOT = path.resolve(__dirname, "..");
32
- const MANIFEST_NAME = "tera-core.manifest.json";
33
- const CONFIG_EXAMPLE = "opencode.tera.example.json";
34
-
35
- /* ---------------------------------- helpers ---------------------------------- */
36
-
37
- const sha256 = (buf) => createHash("sha256").update(buf).digest("hex");
38
-
39
- const toPosix = (p) => p.split(path.sep).join("/");
40
-
41
- function resolveTarget() {
42
- const args = process.argv.slice(2);
43
- for (let i = 0; i < args.length; i++) {
44
- if (args[i] === "--help") {
45
- console.log("Usage: node scripts/install.js [--target <workspaceRoot>]");
46
- process.exit(0);
47
- }
48
- if (args[i] === "--target" && args[i + 1]) {
49
- return path.resolve(args[i + 1]);
50
- }
51
- }
52
- if (process.env.TERA_TARGET_DIR) return path.resolve(process.env.TERA_TARGET_DIR);
53
- if (process.env.INIT_CWD) return path.resolve(process.env.INIT_CWD);
54
- return path.resolve(process.cwd());
55
- }
56
-
57
- function walkFiles(dir) {
58
- const out = [];
59
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
60
- const full = path.join(dir, entry.name);
61
- if (entry.isDirectory()) out.push(...walkFiles(full));
62
- else if (entry.isFile()) out.push(full);
63
- }
64
- return out;
65
- }
66
-
67
- function copyFile(src, dest) {
68
- fs.mkdirSync(path.dirname(dest), { recursive: true });
69
- fs.copyFileSync(src, dest);
70
- }
71
-
72
- function loadOwnership(target) {
73
- const p = path.join(target, ".opencode", MANIFEST_NAME);
74
- if (!fs.existsSync(p)) return { files: {} };
75
- try {
76
- const parsed = JSON.parse(fs.readFileSync(p, "utf8"));
77
- return { files: parsed.files && typeof parsed.files === "object" ? parsed.files : {} };
78
- } catch {
79
- return { files: {} };
80
- }
81
- }
82
-
83
- /* ---------------------------------- state ---------------------------------- */
84
-
85
- const target = resolveTarget();
86
- const pkg = JSON.parse(fs.readFileSync(path.join(PKG_ROOT, "package.json"), "utf8"));
87
- const ownership = loadOwnership(target);
88
-
89
- const stats = { installed: 0, updated: 0, identical: 0, skippedGroups: 0, conflicts: [] };
90
-
91
- /* ------------------------------- group install ------------------------------- */
92
-
93
- /**
94
- * @param {{name:string, src:string, dest:string}} group
95
- */
96
- function installGroup(group) {
97
- if (!fs.existsSync(group.src)) return;
98
- for (const srcFile of walkFiles(group.src)) {
99
- const relSrc = path.relative(group.src, srcFile);
100
- const destFile = path.join(group.dest, relSrc);
101
- const destRel = toPosix(path.relative(target, destFile));
102
- const srcHash = sha256(fs.readFileSync(srcFile));
103
-
104
- if (fs.existsSync(destFile)) {
105
- const destHash = sha256(fs.readFileSync(destFile));
106
- if (destHash === srcHash) {
107
- stats.identical++;
108
- continue;
109
- }
110
- const owned = Object.prototype.hasOwnProperty.call(ownership.files, destRel);
111
- if (owned) {
112
- const bak = `${destFile}.tera-bak-${Date.now()}`;
113
- fs.copyFileSync(destFile, bak);
114
- fs.copyFileSync(srcFile, destFile);
115
- ownership.files[destRel] = srcHash;
116
- stats.updated++;
117
- console.log(` [updated ] ${destRel} (backup: ${path.basename(bak)})`);
118
- } else {
119
- stats.conflicts.push(destRel);
120
- console.log(` [CONFLICT] ${destRel} — kept your file (unowned, not overwritten).`);
121
- }
122
- } else {
123
- copyFile(srcFile, destFile);
124
- ownership.files[destRel] = srcHash;
125
- stats.installed++;
126
- console.log(` [install ] ${destRel}`);
127
- }
128
- }
129
- }
130
-
131
- /* ---------------------------------- config ---------------------------------- */
132
-
133
- function installConfig() {
134
- const configSrc = path.join(PKG_ROOT, CONFIG_EXAMPLE);
135
- if (!fs.existsSync(configSrc)) return;
136
- const configDest = path.join(target, ".opencode", "opencode.json");
137
- const configRel = ".opencode/opencode.json";
138
-
139
- if (fs.existsSync(configDest)) {
140
- const a = sha256(fs.readFileSync(configDest));
141
- const b = sha256(fs.readFileSync(configSrc));
142
- if (a === b) {
143
- stats.identical++;
144
- console.log(` [same ] ${configRel} already identical.`);
145
- } else {
146
- stats.conflicts.push(configRel);
147
- console.log(` [CONFLICT] ${configRel} — kept yours. Reference copy provided below.`);
148
- }
149
- } else {
150
- copyFile(configSrc, configDest);
151
- ownership.files[configRel] = sha256(fs.readFileSync(configDest));
152
- stats.installed++;
153
- console.log(` [install ] ${configRel}`);
154
- }
155
-
156
- // Additive reference copy — always written, never destructive.
157
- const exampleDest = path.join(target, ".opencode", CONFIG_EXAMPLE);
158
- copyFile(configSrc, exampleDest);
159
- console.log(` [reference] .opencode/${CONFIG_EXAMPLE}`);
160
- }
161
-
162
- /* ------------------------------ project-control ------------------------------ */
163
-
164
- function installProjectControl() {
165
- const pcSrc = path.join(PKG_ROOT, "core", "project-control");
166
- if (!fs.existsSync(pcSrc)) return;
167
- const pcDest = path.join(target, "project-control");
168
- if (fs.existsSync(pcDest)) {
169
- stats.skippedGroups++;
170
- console.log(" [SKIP ] project-control/ exists (user-owned state). Skeleton not installed.");
171
- return;
172
- }
173
- for (const f of walkFiles(pcSrc)) {
174
- const relF = toPosix(path.relative(pcSrc, f));
175
- const d = path.join(pcDest, relF);
176
- copyFile(f, d);
177
- ownership.files[`project-control/${relF}`] = sha256(fs.readFileSync(f));
178
- stats.installed++;
179
- console.log(` [install ] project-control/${relF}`);
180
- }
181
- }
182
-
183
- /* ----------------------------------- run ----------------------------------- */
184
-
185
- console.log(`\n@tera/core v${pkg.version} install`);
186
- console.log(` target workspace : ${target}\n`);
187
-
188
- installGroup({ name: "agents", src: path.join(PKG_ROOT, "agents"), dest: path.join(target, ".opencode", "agents") });
189
- installGroup({ name: "commands", src: path.join(PKG_ROOT, "commands"), dest: path.join(target, ".opencode", "commands") });
190
- installGroup({ name: "tera-system", src: path.join(PKG_ROOT, "core", "tera-system"), dest: path.join(target, "tera-system") });
191
- installGroup({ name: "tools", src: path.join(PKG_ROOT, "tools"), dest: path.join(target, "tools") });
192
- installGroup({ name: "scripts", src: path.join(PKG_ROOT, "scripts"), dest: path.join(target, "scripts") });
193
- installConfig();
194
- installProjectControl();
195
-
196
- /* -------------------------------- persist log -------------------------------- */
197
-
198
- const manifest = {
199
- name: pkg.name,
200
- version: pkg.version,
201
- installedAt: new Date().toISOString(),
202
- target,
203
- files: ownership.files,
204
- conflicts: stats.conflicts,
205
- summary: {
206
- installed: stats.installed,
207
- updated: stats.updated,
208
- identical: stats.identical,
209
- skippedGroups: stats.skippedGroups,
210
- conflicts: stats.conflicts.length,
211
- },
212
- };
213
-
214
- const manifestPath = path.join(target, ".opencode", MANIFEST_NAME);
215
- fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
216
- fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
217
-
218
- /* ------------------- license state (non-blocking, never breaks install) ------------------- */
219
-
220
- /**
221
- * License verification with API-first strategy + offline fallback.
222
- *
223
- * Flow:
224
- * 1. Try online API verification (if API URL is configured)
225
- * 2. On API failure → fallback to offline Ed25519 verification
226
- * 3. Never blocks install — always continues
227
- *
228
- * Environment variables:
229
- * TERA_LICENSE_API — API base URL (e.g. https://teranoo.com/api/license)
230
- * TERA_LICENSE_KEY — Manual license key override
231
- */
232
- const LICENSE_API_URL = process.env.TERA_LICENSE_API || "https://teranoo.com/api/license";
233
- const LICENSE_TIMEOUT_MS = 5000; // 5 seconds max for API call
234
-
235
- /**
236
- * Verify license via remote API (online).
237
- * Returns: { ok: boolean, state: string, reason: string, expiresAt?: string, source: "api" }
238
- */
239
- async function verifyViaAPI(licenseKey) {
240
- if (!licenseKey) return { ok: false, state: "Unlicensed", reason: "no-key", source: "api" };
241
-
242
- try {
243
- const controller = new AbortController();
244
- const timeout = setTimeout(() => controller.abort(), LICENSE_TIMEOUT_MS);
245
-
246
- const res = await fetch(`${LICENSE_API_URL}/verify`, {
247
- method: "POST",
248
- headers: { "Content-Type": "application/json" },
249
- body: JSON.stringify({ licenseKey }),
250
- signal: controller.signal,
251
- });
252
- clearTimeout(timeout);
253
-
254
- if (!res.ok) {
255
- return { ok: false, state: "Invalid", reason: `api-http-${res.status}`, source: "api" };
256
- }
257
-
258
- const data = await res.json();
259
- return {
260
- ok: data.valid === true,
261
- state: data.state || "Invalid",
262
- reason: data.reason || "api-response",
263
- expiresAt: data.expiresAt || null,
264
- source: "api",
265
- };
266
- } catch (err) {
267
- // Network error, timeout, abort — fall through to offline
268
- return { ok: false, state: "Unknown", reason: `api-error: ${err.message}`, source: "api-fallback" };
269
- }
270
- }
271
-
272
- /**
273
- * Verify license offline (Ed25519 signature check).
274
- * Returns: { ok: boolean, state: string, reason: string, expiresAt?: string, source: "offline" }
275
- */
276
- function verifyOffline(licenseKey) {
277
- const state = assessLicenseState(licenseKey);
278
- return {
279
- ok: state.state === "Active" || state.state === "Grace",
280
- state: state.state,
281
- reason: state.reason,
282
- expiresAt: state.expiresAt || null,
283
- graceUntil: state.graceUntil || null,
284
- source: "offline",
285
- };
286
- }
287
-
288
- /**
289
- * Combined verification: API-first, offline fallback.
290
- * Always returns a result — never throws.
291
- */
292
- async function verifyLicense(licenseKey) {
293
- // Step 1: Try API (unless explicitly disabled)
294
- if (process.env.TERA_OFFLINE_ONLY === "1") {
295
- return verifyOffline(licenseKey);
296
- }
297
-
298
- const apiResult = await verifyViaAPI(licenseKey);
299
-
300
- // If API succeeded and gave a definitive answer, use it
301
- if (apiResult.source === "api" && apiResult.ok !== undefined) {
302
- return apiResult;
303
- }
304
-
305
- // Step 2: Fallback to offline
306
- const offlineResult = verifyOffline(licenseKey);
307
-
308
- // If offline confirms API's failure, use offline (more trusted)
309
- if (!apiResult.ok && !offlineResult.ok) {
310
- return offlineResult;
311
- }
312
-
313
- // If API failed but offline says active, trust offline (API might be down)
314
- if (!apiResult.ok && offlineResult.ok) {
315
- return { ...offlineResult, reason: `offline-verified (api-unavailable: ${apiResult.reason})` };
316
- }
317
-
318
- // Default: trust API
319
- return apiResult;
320
- }
321
-
322
- /* ------------------- server sync integrity license ------------------- */
323
- const SYNC_URL = process.env.TERA_SYNC_URL || "https://teranoo.com/api/tera-system/bundle";
324
-
325
- /**
326
- * Attempt server sync (best-effort, never breaks install).
327
- * Downloads the signed bundle, verifies signature + hashes, and applies.
328
- * If unreachable/rejected → bundled content remains (thick mode).
329
- */
330
- async function tryServerSync() {
331
- try {
332
- const controller = new AbortController();
333
- const timeout = setTimeout(() => controller.abort(), 8000);
334
- const res = await fetch(SYNC_URL, { signal: controller.signal });
335
- clearTimeout(timeout);
336
- if (!res.ok) {
337
- console.log(` [sync ] server HTTP ${res.status} using bundled content`);
338
- return false;
339
- }
340
- const bundle = await res.json();
341
- const v = verifyBundle(bundle);
342
- if (!v.ok) {
343
- console.log(` [sync ] bundle rejected (${v.reason}) using bundled content`);
344
- return false;
345
- }
346
- const r = applyBundle(bundle, target);
347
- console.log(` [sync ] server bundle applied — ${r.applied} files (signed & verified)`);
348
- return true;
349
- } catch {
350
- console.log(` [sync ] server unreachable — using bundled content`);
351
- return false;
352
- }
353
- }
354
-
355
- // Run: sync → integrity manifest → license check (async wrapper, non-blocking)
356
- (async () => {
357
- try {
358
- // Step 1: best-effort server sync (self-healing / fresh content)
359
- await tryServerSync();
360
-
361
- // Step 2: integrity manifest (after sync, covers server files too)
362
- try {
363
- const integrityManifest = createIntegrityManifest(target);
364
- saveIntegrityManifest(target, integrityManifest);
365
- console.log(` [integrity] manifest created (${integrityManifest.fileCount} files tracked)`);
366
- } catch (err) {
367
- console.log(` [integrity] skipped (${err.message}) install continues`);
368
- }
369
-
370
- // Step 3: license verification (API-first, offline fallback)
371
- const stateFile = path.join(target, ".tera", "license.state.json");
372
- let licenseKey = (readLicenseState(stateFile) || {}).licenseKey;
373
- if (!licenseKey) licenseKey = process.env.TERA_LICENSE_KEY || undefined;
374
-
375
- const result = await verifyLicense(licenseKey);
376
-
377
- writeLicenseState(stateFile, {
378
- state: result.state,
379
- reason: result.reason,
380
- licenseKey: licenseKey || null,
381
- expiresAt: result.expiresAt || null,
382
- graceUntil: result.graceUntil || null,
383
- source: result.source,
384
- checkedAt: new Date().toISOString(),
385
- });
386
-
387
- const sourceTag = result.source === "api" ? "☁️" : result.source === "offline" ? "🔒" : "🔄";
388
- console.log(` [license ] ${sourceTag} state=${result.state} (${result.reason})`);
389
- } catch (err) {
390
- console.log(` [license ] skipped (${err.message}) install continues`);
391
- }
392
-
393
- // final summary (printed after sync/integrity/license complete)
394
- console.log("\n summary:");
395
- console.log(` installed : ${stats.installed}`);
396
- console.log(` updated : ${stats.updated}`);
397
- console.log(` identical : ${stats.identical}`);
398
- console.log(` conflicts : ${stats.conflicts.length} (your files kept untouched)`);
399
- console.log(` skipped : ${stats.skippedGroups} group(s) — user-owned state`);
400
- console.log(` ownership log : ${path.join(".opencode", MANIFEST_NAME)}\n`);
401
-
402
- if (stats.conflicts.length > 0) {
403
- console.log(" ⚠ conflicts — your files were kept. Review them before relying on Tera content.");
404
- process.exitCode = 0;
405
- }
406
- })();
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @tera/core installer
4
+ * --------------------------------------------------------------
5
+ * NON-DESTRUCTIVE + OWNERSHIP-AWARE installer for the Tera System
6
+ * OpenCode Plugin component.
7
+ *
8
+ * Rules:
9
+ * - Never deletes anything.
10
+ * - Never overwrites a file it does not own.
11
+ * - Tracks ownership via `.opencode/tera-core.manifest.json`.
12
+ * - Only updates files it owns, always keeping a backup first.
13
+ * - Idempotent: re-running is safe; identical files are skipped.
14
+ * - Never touches an existing user `opencode.json`.
15
+ *
16
+ * Target resolution (first match wins):
17
+ * 1. `--target <dir>` argument
18
+ * 2. `TERA_TARGET_DIR` environment variable
19
+ * 3. `INIT_CWD` (set by npm for lifecycle scripts)
20
+ * 4. current working directory
21
+ */
22
+ import { createHash } from "node:crypto";
23
+ import fs from "node:fs";
24
+ import path from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+ import { assessLicenseState, readLicenseState, writeLicenseState } from "./lib/license.mjs";
27
+ import { createIntegrityManifest, saveIntegrityManifest } from "./lib/integrity.mjs";
28
+ import { verifyBundle, applyBundle } from "./lib/bundle.mjs";
29
+
30
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
31
+ const PKG_ROOT = path.resolve(__dirname, "..");
32
+ const MANIFEST_NAME = "tera-core.manifest.json";
33
+ const CONFIG_EXAMPLE = "opencode.tera.example.json";
34
+
35
+ /* ---------------------------------- helpers ---------------------------------- */
36
+
37
+ const sha256 = (buf) => createHash("sha256").update(buf).digest("hex");
38
+
39
+ const toPosix = (p) => p.split(path.sep).join("/");
40
+
41
+ function resolveTarget() {
42
+ const args = process.argv.slice(2);
43
+ for (let i = 0; i < args.length; i++) {
44
+ if (args[i] === "--help") {
45
+ console.log("Usage: node scripts/install.js [--target <workspaceRoot>]");
46
+ process.exit(0);
47
+ }
48
+ if (args[i] === "--target" && args[i + 1]) {
49
+ return path.resolve(args[i + 1]);
50
+ }
51
+ }
52
+ if (process.env.TERA_TARGET_DIR) return path.resolve(process.env.TERA_TARGET_DIR);
53
+ if (process.env.INIT_CWD) return path.resolve(process.env.INIT_CWD);
54
+ return path.resolve(process.cwd());
55
+ }
56
+
57
+ function walkFiles(dir) {
58
+ const out = [];
59
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
60
+ const full = path.join(dir, entry.name);
61
+ if (entry.isDirectory()) out.push(...walkFiles(full));
62
+ else if (entry.isFile()) out.push(full);
63
+ }
64
+ return out;
65
+ }
66
+
67
+ function copyFile(src, dest) {
68
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
69
+ fs.copyFileSync(src, dest);
70
+ }
71
+
72
+ function loadOwnership(target) {
73
+ const p = path.join(target, ".opencode", MANIFEST_NAME);
74
+ if (!fs.existsSync(p)) return { files: {} };
75
+ try {
76
+ const parsed = JSON.parse(fs.readFileSync(p, "utf8"));
77
+ return { files: parsed.files && typeof parsed.files === "object" ? parsed.files : {} };
78
+ } catch {
79
+ return { files: {} };
80
+ }
81
+ }
82
+
83
+ /* ---------------------------------- state ---------------------------------- */
84
+
85
+ const target = resolveTarget();
86
+ const pkg = JSON.parse(fs.readFileSync(path.join(PKG_ROOT, "package.json"), "utf8"));
87
+ const ownership = loadOwnership(target);
88
+
89
+ const stats = { installed: 0, updated: 0, identical: 0, adopted: 0, skippedGroups: 0, conflicts: [] };
90
+
91
+ /* ------------------------------- group install ------------------------------- */
92
+
93
+ /**
94
+ * @param {{name:string, src:string, dest:string}} group
95
+ */
96
+ function installGroup(group) {
97
+ if (!fs.existsSync(group.src)) return;
98
+ for (const srcFile of walkFiles(group.src)) {
99
+ const relSrc = path.relative(group.src, srcFile);
100
+ const destFile = path.join(group.dest, relSrc);
101
+ const destRel = toPosix(path.relative(target, destFile));
102
+ const srcHash = sha256(fs.readFileSync(srcFile));
103
+
104
+ if (fs.existsSync(destFile)) {
105
+ const destHash = sha256(fs.readFileSync(destFile));
106
+ if (destHash === srcHash) {
107
+ // Identical content — adopt ownership if not already owned.
108
+ // (byte-identical file = package content, so it is safe to claim it.)
109
+ if (!Object.prototype.hasOwnProperty.call(ownership.files, destRel)) {
110
+ ownership.files[destRel] = srcHash;
111
+ stats.adopted++;
112
+ console.log(` [adopt ] ${destRel} identical, now owned by package`);
113
+ } else {
114
+ stats.identical++;
115
+ }
116
+ continue;
117
+ }
118
+ const owned = Object.prototype.hasOwnProperty.call(ownership.files, destRel);
119
+ if (owned) {
120
+ const bak = `${destFile}.tera-bak-${Date.now()}`;
121
+ fs.copyFileSync(destFile, bak);
122
+ fs.copyFileSync(srcFile, destFile);
123
+ ownership.files[destRel] = srcHash;
124
+ stats.updated++;
125
+ console.log(` [updated ] ${destRel} (backup: ${path.basename(bak)})`);
126
+ } else {
127
+ stats.conflicts.push(destRel);
128
+ console.log(` [CONFLICT] ${destRel} — kept your file (unowned, not overwritten).`);
129
+ }
130
+ } else {
131
+ copyFile(srcFile, destFile);
132
+ ownership.files[destRel] = srcHash;
133
+ stats.installed++;
134
+ console.log(` [install ] ${destRel}`);
135
+ }
136
+ }
137
+ }
138
+
139
+ /* ---------------------------------- config ---------------------------------- */
140
+
141
+ function installConfig() {
142
+ const configSrc = path.join(PKG_ROOT, CONFIG_EXAMPLE);
143
+ if (!fs.existsSync(configSrc)) return;
144
+ const configDest = path.join(target, ".opencode", "opencode.json");
145
+ const configRel = ".opencode/opencode.json";
146
+
147
+ if (fs.existsSync(configDest)) {
148
+ const a = sha256(fs.readFileSync(configDest));
149
+ const b = sha256(fs.readFileSync(configSrc));
150
+ if (a === b) {
151
+ stats.identical++;
152
+ console.log(` [same ] ${configRel} — already identical.`);
153
+ } else {
154
+ stats.conflicts.push(configRel);
155
+ console.log(` [CONFLICT] ${configRel} — kept yours. Reference copy provided below.`);
156
+ }
157
+ } else {
158
+ copyFile(configSrc, configDest);
159
+ ownership.files[configRel] = sha256(fs.readFileSync(configDest));
160
+ stats.installed++;
161
+ console.log(` [install ] ${configRel}`);
162
+ }
163
+
164
+ // Additive reference copy — always written, never destructive.
165
+ const exampleDest = path.join(target, ".opencode", CONFIG_EXAMPLE);
166
+ copyFile(configSrc, exampleDest);
167
+ console.log(` [reference] .opencode/${CONFIG_EXAMPLE}`);
168
+ }
169
+
170
+ /* ------------------------------ project-control ------------------------------ */
171
+
172
+ function installProjectControl() {
173
+ const pcSrc = path.join(PKG_ROOT, "core", "project-control");
174
+ if (!fs.existsSync(pcSrc)) return;
175
+ const pcDest = path.join(target, "project-control");
176
+ if (fs.existsSync(pcDest)) {
177
+ stats.skippedGroups++;
178
+ console.log(" [SKIP ] project-control/ — exists (user-owned state). Skeleton not installed.");
179
+ return;
180
+ }
181
+ for (const f of walkFiles(pcSrc)) {
182
+ const relF = toPosix(path.relative(pcSrc, f));
183
+ const d = path.join(pcDest, relF);
184
+ copyFile(f, d);
185
+ ownership.files[`project-control/${relF}`] = sha256(fs.readFileSync(f));
186
+ stats.installed++;
187
+ console.log(` [install ] project-control/${relF}`);
188
+ }
189
+ }
190
+
191
+ /* ----------------------------------- run ----------------------------------- */
192
+
193
+ console.log(`\n@tera/core v${pkg.version} — install`);
194
+ console.log(` target workspace : ${target}\n`);
195
+
196
+ installGroup({ name: "agents", src: path.join(PKG_ROOT, "agents"), dest: path.join(target, ".opencode", "agents") });
197
+ installGroup({ name: "commands", src: path.join(PKG_ROOT, "commands"), dest: path.join(target, ".opencode", "commands") });
198
+ installGroup({ name: "tera-system", src: path.join(PKG_ROOT, "core", "tera-system"), dest: path.join(target, "tera-system") });
199
+ installGroup({ name: "tools", src: path.join(PKG_ROOT, "tools"), dest: path.join(target, "tools") });
200
+ installGroup({ name: "scripts", src: path.join(PKG_ROOT, "scripts"), dest: path.join(target, "scripts") });
201
+ installConfig();
202
+ installProjectControl();
203
+
204
+ /* -------------------------------- persist log -------------------------------- */
205
+
206
+ const manifest = {
207
+ name: pkg.name,
208
+ version: pkg.version,
209
+ installedAt: new Date().toISOString(),
210
+ target,
211
+ files: ownership.files,
212
+ conflicts: stats.conflicts,
213
+ summary: {
214
+ installed: stats.installed,
215
+ updated: stats.updated,
216
+ identical: stats.identical,
217
+ adopted: stats.adopted,
218
+ skippedGroups: stats.skippedGroups,
219
+ conflicts: stats.conflicts.length,
220
+ },
221
+ };
222
+
223
+ const manifestPath = path.join(target, ".opencode", MANIFEST_NAME);
224
+ fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
225
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
226
+
227
+ /* ------------------- license state (non-blocking, never breaks install) ------------------- */
228
+
229
+ /**
230
+ * License verification with API-first strategy + offline fallback.
231
+ *
232
+ * Flow:
233
+ * 1. Try online API verification (if API URL is configured)
234
+ * 2. On API failure → fallback to offline Ed25519 verification
235
+ * 3. Never blocks install — always continues
236
+ *
237
+ * Environment variables:
238
+ * TERA_LICENSE_API — API base URL (e.g. https://teranoo.com/api/license)
239
+ * TERA_LICENSE_KEY — Manual license key override
240
+ */
241
+ const LICENSE_API_URL = process.env.TERA_LICENSE_API || "https://teranoo.com/api/license";
242
+ const LICENSE_TIMEOUT_MS = 5000; // 5 seconds max for API call
243
+
244
+ /**
245
+ * Verify license via remote API (online).
246
+ * Returns: { ok: boolean, state: string, reason: string, expiresAt?: string, source: "api" }
247
+ */
248
+ async function verifyViaAPI(licenseKey) {
249
+ if (!licenseKey) return { ok: false, state: "Unlicensed", reason: "no-key", source: "api" };
250
+
251
+ try {
252
+ const controller = new AbortController();
253
+ const timeout = setTimeout(() => controller.abort(), LICENSE_TIMEOUT_MS);
254
+
255
+ const res = await fetch(`${LICENSE_API_URL}/verify`, {
256
+ method: "POST",
257
+ headers: { "Content-Type": "application/json" },
258
+ body: JSON.stringify({ licenseKey }),
259
+ signal: controller.signal,
260
+ });
261
+ clearTimeout(timeout);
262
+
263
+ if (!res.ok) {
264
+ return { ok: false, state: "Invalid", reason: `api-http-${res.status}`, source: "api" };
265
+ }
266
+
267
+ const data = await res.json();
268
+ return {
269
+ ok: data.valid === true,
270
+ state: data.state || "Invalid",
271
+ reason: data.reason || "api-response",
272
+ expiresAt: data.expiresAt || null,
273
+ source: "api",
274
+ };
275
+ } catch (err) {
276
+ // Network error, timeout, abort — fall through to offline
277
+ return { ok: false, state: "Unknown", reason: `api-error: ${err.message}`, source: "api-fallback" };
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Verify license offline (Ed25519 signature check).
283
+ * Returns: { ok: boolean, state: string, reason: string, expiresAt?: string, source: "offline" }
284
+ */
285
+ function verifyOffline(licenseKey) {
286
+ const state = assessLicenseState(licenseKey);
287
+ return {
288
+ ok: state.state === "Active" || state.state === "Grace",
289
+ state: state.state,
290
+ reason: state.reason,
291
+ expiresAt: state.expiresAt || null,
292
+ graceUntil: state.graceUntil || null,
293
+ source: "offline",
294
+ };
295
+ }
296
+
297
+ /**
298
+ * Combined verification: API-first, offline fallback.
299
+ * Always returns a result — never throws.
300
+ */
301
+ async function verifyLicense(licenseKey) {
302
+ // Step 1: Try API (unless explicitly disabled)
303
+ if (process.env.TERA_OFFLINE_ONLY === "1") {
304
+ return verifyOffline(licenseKey);
305
+ }
306
+
307
+ const apiResult = await verifyViaAPI(licenseKey);
308
+
309
+ // If API succeeded and gave a definitive answer, use it
310
+ if (apiResult.source === "api" && apiResult.ok !== undefined) {
311
+ return apiResult;
312
+ }
313
+
314
+ // Step 2: Fallback to offline
315
+ const offlineResult = verifyOffline(licenseKey);
316
+
317
+ // If offline confirms API's failure, use offline (more trusted)
318
+ if (!apiResult.ok && !offlineResult.ok) {
319
+ return offlineResult;
320
+ }
321
+
322
+ // If API failed but offline says active, trust offline (API might be down)
323
+ if (!apiResult.ok && offlineResult.ok) {
324
+ return { ...offlineResult, reason: `offline-verified (api-unavailable: ${apiResult.reason})` };
325
+ }
326
+
327
+ // Default: trust API
328
+ return apiResult;
329
+ }
330
+
331
+ /* ------------------- server sync → integrity → license ------------------- */
332
+ const SYNC_URL = process.env.TERA_SYNC_URL || "https://teranoo.com/api/tera-system/bundle";
333
+
334
+ /**
335
+ * Attempt server sync (best-effort, never breaks install).
336
+ * Downloads the signed bundle, verifies signature + hashes, and applies.
337
+ * If unreachable/rejected bundled content remains (thick mode).
338
+ */
339
+ async function tryServerSync() {
340
+ try {
341
+ const controller = new AbortController();
342
+ const timeout = setTimeout(() => controller.abort(), 8000);
343
+ const res = await fetch(SYNC_URL, { signal: controller.signal });
344
+ clearTimeout(timeout);
345
+ if (!res.ok) {
346
+ console.log(` [sync ] server HTTP ${res.status} — using bundled content`);
347
+ return false;
348
+ }
349
+ const bundle = await res.json();
350
+ const v = verifyBundle(bundle);
351
+ if (!v.ok) {
352
+ console.log(` [sync ] bundle rejected (${v.reason}) — using bundled content`);
353
+ return false;
354
+ }
355
+ const r = applyBundle(bundle, target);
356
+ console.log(` [sync ] server bundle applied — ${r.applied} files (signed & verified)`);
357
+ return true;
358
+ } catch {
359
+ console.log(` [sync ] server unreachable — using bundled content`);
360
+ return false;
361
+ }
362
+ }
363
+
364
+ // Run: sync → integrity manifest → license check (async wrapper, non-blocking)
365
+ (async () => {
366
+ try {
367
+ // Step 1: best-effort server sync (self-healing / fresh content)
368
+ await tryServerSync();
369
+
370
+ // Step 2: integrity manifest (after sync, covers server files too)
371
+ try {
372
+ const integrityManifest = createIntegrityManifest(target);
373
+ saveIntegrityManifest(target, integrityManifest);
374
+ console.log(` [integrity] manifest created (${integrityManifest.fileCount} files tracked)`);
375
+ } catch (err) {
376
+ console.log(` [integrity] skipped (${err.message}) — install continues`);
377
+ }
378
+
379
+ // Step 3: license verification (API-first, offline fallback)
380
+ const stateFile = path.join(target, ".tera", "license.state.json");
381
+ let licenseKey = (readLicenseState(stateFile) || {}).licenseKey;
382
+ if (!licenseKey) licenseKey = process.env.TERA_LICENSE_KEY || undefined;
383
+
384
+ const result = await verifyLicense(licenseKey);
385
+
386
+ writeLicenseState(stateFile, {
387
+ state: result.state,
388
+ reason: result.reason,
389
+ licenseKey: licenseKey || null,
390
+ expiresAt: result.expiresAt || null,
391
+ graceUntil: result.graceUntil || null,
392
+ source: result.source,
393
+ checkedAt: new Date().toISOString(),
394
+ });
395
+
396
+ const sourceTag = result.source === "api" ? "☁️" : result.source === "offline" ? "🔒" : "🔄";
397
+ console.log(` [license ] ${sourceTag} state=${result.state} (${result.reason})`);
398
+ } catch (err) {
399
+ console.log(` [license ] skipped (${err.message}) — install continues`);
400
+ }
401
+
402
+ // final summary (printed after sync/integrity/license complete)
403
+ console.log("\n summary:");
404
+ console.log(` installed : ${stats.installed}`);
405
+ console.log(` updated : ${stats.updated}`);
406
+ console.log(` identical : ${stats.identical}`);
407
+ console.log(` adopted : ${stats.adopted} (pre-existing identical files now owned)`);
408
+ console.log(` conflicts : ${stats.conflicts.length} (your files kept untouched)`);
409
+ console.log(` skipped : ${stats.skippedGroups} group(s) — user-owned state`);
410
+ console.log(` ownership log : ${path.join(".opencode", MANIFEST_NAME)}\n`);
411
+
412
+ if (stats.conflicts.length > 0) {
413
+ console.log(" ⚠ conflicts — your files were kept. Review them before relying on Tera content.");
414
+ process.exitCode = 0;
415
+ }
416
+ })();