arkaos 4.34.0 → 4.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/THE-ARKAOS-GUIDE.md +1 -1
- package/VERSION +1 -1
- package/bin/arka-menubar.py +460 -0
- package/config/install-profiles.json +75 -0
- package/core/runtime/opencode.py +252 -0
- package/core/runtime/registry.py +6 -1
- package/harness/codex/AGENTS.md +1 -1
- package/harness/copilot/copilot-instructions.md +1 -1
- package/harness/cursor/rules/arkaos.mdc +2 -2
- package/harness/gemini/GEMINI.md +1 -1
- package/harness/opencode/AGENTS.md +1 -1
- package/harness/opencode/agents/arka-architect-gabriel.md +12 -0
- package/harness/opencode/agents/arka-brand-director-valentina.md +12 -0
- package/harness/opencode/agents/arka-cfo-helena.md +12 -0
- package/harness/opencode/agents/arka-chief-of-staff-afonso.md +12 -0
- package/harness/opencode/agents/arka-community-strategist-beatriz.md +12 -0
- package/harness/opencode/agents/arka-content-strategist-rafael.md +12 -0
- package/harness/opencode/agents/arka-conversion-strategist-ines.md +12 -0
- package/harness/opencode/agents/arka-coo-sofia.md +12 -0
- package/harness/opencode/agents/arka-copy-director-eduardo.md +12 -0
- package/harness/opencode/agents/arka-cqo-marta.md +12 -0
- package/harness/opencode/agents/arka-cto-marco.md +12 -0
- package/harness/opencode/agents/arka-design-ops-lead-iris.md +12 -0
- package/harness/opencode/agents/arka-ecom-director-ricardo.md +12 -0
- package/harness/opencode/agents/arka-knowledge-director-clara.md +12 -0
- package/harness/opencode/agents/arka-leadership-director-rodrigo.md +12 -0
- package/harness/opencode/agents/arka-marketing-director-luna.md +12 -0
- package/harness/opencode/agents/arka-ops-lead-daniel.md +12 -0
- package/harness/opencode/agents/arka-pm-director-carolina.md +12 -0
- package/harness/opencode/agents/arka-revops-lead-vicente.md +12 -0
- package/harness/opencode/agents/arka-saas-strategist-tiago.md +12 -0
- package/harness/opencode/agents/arka-sales-director-miguel.md +12 -0
- package/harness/opencode/agents/arka-strategy-director-tomas.md +12 -0
- package/harness/opencode/agents/arka-tech-director-francisca.md +12 -0
- package/harness/opencode/agents/arka-tech-lead-paulo.md +12 -0
- package/harness/opencode/agents/arka-video-producer-simao.md +12 -0
- package/harness/opencode/commands/arka-brand.md +9 -0
- package/harness/opencode/commands/arka-community.md +9 -0
- package/harness/opencode/commands/arka-content.md +9 -0
- package/harness/opencode/commands/arka-dev.md +9 -0
- package/harness/opencode/commands/arka-ecom.md +9 -0
- package/harness/opencode/commands/arka-fin.md +9 -0
- package/harness/opencode/commands/arka-kb.md +9 -0
- package/harness/opencode/commands/arka-landing.md +9 -0
- package/harness/opencode/commands/arka-lead.md +9 -0
- package/harness/opencode/commands/arka-mkt.md +9 -0
- package/harness/opencode/commands/arka-ops.md +9 -0
- package/harness/opencode/commands/arka-org.md +9 -0
- package/harness/opencode/commands/arka-pm.md +9 -0
- package/harness/opencode/commands/arka-saas.md +9 -0
- package/harness/opencode/commands/arka-sales.md +9 -0
- package/harness/opencode/commands/arka-strat.md +9 -0
- package/harness/opencode/opencode.json +16 -0
- package/harness/zed/.rules +1 -1
- package/installer/adapters/opencode.js +118 -0
- package/installer/cli.js +31 -1
- package/installer/detect-runtime.js +18 -0
- package/installer/doctor.js +202 -2
- package/installer/index.js +174 -88
- package/installer/menubar.js +262 -0
- package/installer/product-stats.js +93 -0
- package/installer/profile.js +75 -0
- package/installer/prompts.js +303 -41
- package/installer/services.js +487 -0
- package/installer/ui.js +190 -0
- package/installer/update.js +212 -95
- package/knowledge/skills-manifest.json +1 -1
- package/package.json +6 -1
- package/pyproject.toml +1 -1
- package/scripts/harness_gen.py +98 -1
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Profile → service reconciliation (Foundation PR-4).
|
|
3
|
+
*
|
|
4
|
+
* Reads config/install-profiles.json (data-driven manifest: profiles
|
|
5
|
+
* extend each other, services carry a kind + probe metadata) and brings
|
|
6
|
+
* a machine up to its persisted install profile:
|
|
7
|
+
*
|
|
8
|
+
* probe installed? → present (zero side-effects, idempotent)
|
|
9
|
+
* missing → install via the EXISTING helpers (pipInstall,
|
|
10
|
+
* package-manager, system-tools, graphify) — or a
|
|
11
|
+
* hint when the install needs consent we don't have
|
|
12
|
+
*
|
|
13
|
+
* Consent rules (.claude/rules/node-installer.md + campaign spec):
|
|
14
|
+
* - NEVER sudo, NEVER brew/winget without consent.
|
|
15
|
+
* - Headless runs (update daemon, CI) only touch consent-free
|
|
16
|
+
* services (pip into our own venv, ollama pull of the configured
|
|
17
|
+
* model); everything else reports a copy-paste hint.
|
|
18
|
+
* - NEVER uninstalls or downgrades anything.
|
|
19
|
+
*
|
|
20
|
+
* Effects are injected: `defaultEffects()` carries the real
|
|
21
|
+
* implementations; tests inject stubs so no real install ever runs in
|
|
22
|
+
* the suite (feedback_destructive_tests).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { execSync } from "node:child_process";
|
|
26
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
import { homedir } from "node:os";
|
|
29
|
+
import { getArkaosPython, pipInstall } from "./python-resolver.js";
|
|
30
|
+
import { checkOllama, ensureSystemTools } from "./system-tools.js";
|
|
31
|
+
import {
|
|
32
|
+
buildInstallCommand,
|
|
33
|
+
detectPackageManager,
|
|
34
|
+
installViaPackageManager,
|
|
35
|
+
managerNeedsSudo,
|
|
36
|
+
} from "./package-manager.js";
|
|
37
|
+
import { ensureGraphify, graphifyDoctor } from "./graphify.js";
|
|
38
|
+
import { CMD_FINDER } from "./platform.js";
|
|
39
|
+
import { DEFAULT_PROFILE, normalizeProfileFlag } from "./profile.js";
|
|
40
|
+
|
|
41
|
+
// ── Manifest ──────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Load and validate config/install-profiles.json. Throws a descriptive
|
|
45
|
+
* Error on structural problems (missing sections, dangling service
|
|
46
|
+
* refs, dangling/cyclic extends) so a broken manifest fails loudly in
|
|
47
|
+
* tests and surfaces as a doctor warn at runtime — callers wrap in
|
|
48
|
+
* try/catch.
|
|
49
|
+
*/
|
|
50
|
+
export function loadProfilesManifest(repoRoot) {
|
|
51
|
+
const path = join(repoRoot, "config", "install-profiles.json");
|
|
52
|
+
const manifest = JSON.parse(readFileSync(path, "utf-8"));
|
|
53
|
+
validateManifest(manifest);
|
|
54
|
+
return manifest;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function validateManifest(manifest) {
|
|
58
|
+
if (!manifest || typeof manifest !== "object") {
|
|
59
|
+
throw new Error("install-profiles manifest is not an object");
|
|
60
|
+
}
|
|
61
|
+
const { profiles, services } = manifest;
|
|
62
|
+
if (!profiles || typeof profiles !== "object" || Object.keys(profiles).length === 0) {
|
|
63
|
+
throw new Error("install-profiles manifest has no profiles");
|
|
64
|
+
}
|
|
65
|
+
if (!services || typeof services !== "object") {
|
|
66
|
+
throw new Error("install-profiles manifest has no services");
|
|
67
|
+
}
|
|
68
|
+
for (const [name, profile] of Object.entries(profiles)) {
|
|
69
|
+
if (!Array.isArray(profile.services)) {
|
|
70
|
+
throw new Error(`profile "${name}" has no services array`);
|
|
71
|
+
}
|
|
72
|
+
for (const id of profile.services) {
|
|
73
|
+
if (!services[id]) {
|
|
74
|
+
throw new Error(`profile "${name}" references unknown service "${id}"`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (profile.extends !== undefined && !profiles[profile.extends]) {
|
|
78
|
+
throw new Error(`profile "${name}" extends unknown profile "${profile.extends}"`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// Acyclic extends: walk every chain; a revisit is a cycle.
|
|
82
|
+
for (const name of Object.keys(profiles)) {
|
|
83
|
+
const seen = new Set();
|
|
84
|
+
let cursor = name;
|
|
85
|
+
while (cursor !== undefined) {
|
|
86
|
+
if (seen.has(cursor)) {
|
|
87
|
+
throw new Error(`cyclic extends chain at profile "${cursor}"`);
|
|
88
|
+
}
|
|
89
|
+
seen.add(cursor);
|
|
90
|
+
cursor = profiles[cursor].extends;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
for (const [id, svc] of Object.entries(services)) {
|
|
94
|
+
if (!svc || typeof svc !== "object" || typeof svc.kind !== "string") {
|
|
95
|
+
throw new Error(`service "${id}" has no kind`);
|
|
96
|
+
}
|
|
97
|
+
for (const dep of svc.requires || []) {
|
|
98
|
+
if (!services[dep]) {
|
|
99
|
+
throw new Error(`service "${id}" requires unknown service "${dep}"`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Resolve the ordered, deduplicated service list for a profile:
|
|
107
|
+
* extends-parents first (essential before complete before local-ai),
|
|
108
|
+
* preserving each profile's declared order. Pure.
|
|
109
|
+
*/
|
|
110
|
+
export function resolveServicesForProfile(name, manifest) {
|
|
111
|
+
const profiles = manifest.profiles;
|
|
112
|
+
const canonical = normalizeProfileFlag(name) || name;
|
|
113
|
+
if (!profiles[canonical]) {
|
|
114
|
+
throw new Error(`unknown install profile: ${name}`);
|
|
115
|
+
}
|
|
116
|
+
const chain = [];
|
|
117
|
+
const seen = new Set();
|
|
118
|
+
let cursor = canonical;
|
|
119
|
+
while (cursor !== undefined) {
|
|
120
|
+
if (seen.has(cursor)) {
|
|
121
|
+
throw new Error(`cyclic extends chain at profile "${cursor}"`);
|
|
122
|
+
}
|
|
123
|
+
seen.add(cursor);
|
|
124
|
+
chain.unshift(cursor); // parent-first
|
|
125
|
+
cursor = profiles[cursor].extends;
|
|
126
|
+
if (cursor !== undefined && !profiles[cursor]) {
|
|
127
|
+
throw new Error(`profile extends unknown profile "${cursor}"`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const out = [];
|
|
131
|
+
const dedup = new Set();
|
|
132
|
+
for (const profileName of chain) {
|
|
133
|
+
for (const id of profiles[profileName].services || []) {
|
|
134
|
+
if (!dedup.has(id)) {
|
|
135
|
+
dedup.add(id);
|
|
136
|
+
out.push(id);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── Model Fabric: execution model parser ─────────────────────────────────
|
|
144
|
+
|
|
145
|
+
// Minimal, tolerant YAML-subset parser (nested maps of scalars, 2-space
|
|
146
|
+
// indent) — enough for ~/.arkaos/models.yaml, ZERO new dependencies.
|
|
147
|
+
// Unparseable lines and lists are skipped, never thrown on.
|
|
148
|
+
function parseSimpleYaml(text) {
|
|
149
|
+
const root = {};
|
|
150
|
+
const stack = [{ indent: -1, node: root }];
|
|
151
|
+
for (const rawLine of String(text).split(/\r?\n/)) {
|
|
152
|
+
const line = rawLine.replace(/\t/g, " ");
|
|
153
|
+
const trimmed = line.trim();
|
|
154
|
+
if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("- ")) continue;
|
|
155
|
+
const m = trimmed.match(/^([A-Za-z0-9_./-]+):\s*(.*)$/);
|
|
156
|
+
if (!m) continue;
|
|
157
|
+
const indent = line.length - line.trimStart().length;
|
|
158
|
+
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
|
|
159
|
+
stack.pop();
|
|
160
|
+
}
|
|
161
|
+
const parent = stack[stack.length - 1].node;
|
|
162
|
+
const key = m[1];
|
|
163
|
+
// A value that is only a comment (`key: # note`) is an empty scalar
|
|
164
|
+
// in YAML — treat it like `key:` (nested map or empty).
|
|
165
|
+
const rawValue = m[2].startsWith("#") ? "" : m[2];
|
|
166
|
+
if (rawValue === "") {
|
|
167
|
+
const child = {};
|
|
168
|
+
parent[key] = child;
|
|
169
|
+
stack.push({ indent, node: child });
|
|
170
|
+
} else {
|
|
171
|
+
parent[key] = unquoteScalar(rawValue);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return root;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// YAML scalar semantics for the subset we accept: a quoted value keeps
|
|
178
|
+
// everything between the quotes (a "#" inside quotes is data); an
|
|
179
|
+
// unquoted value ends at the first whitespace-preceded "#" (inline
|
|
180
|
+
// comment), matching the canonical Python reader.
|
|
181
|
+
function unquoteScalar(raw) {
|
|
182
|
+
const quote = raw[0];
|
|
183
|
+
if (quote === '"' || quote === "'") {
|
|
184
|
+
const end = raw.indexOf(quote, 1);
|
|
185
|
+
if (end !== -1) return raw.slice(1, end);
|
|
186
|
+
}
|
|
187
|
+
const hash = raw.search(/\s#/);
|
|
188
|
+
return (hash === -1 ? raw : raw.slice(0, hash)).trim();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Read the Model Fabric execution model from ~/.arkaos/models.yaml.
|
|
193
|
+
* Returns { model, effort } only when roles.execution routes to the
|
|
194
|
+
* ollama provider with a non-empty model (alias slots best/default/fast
|
|
195
|
+
* resolve through aliases.ollama). Missing file, corrupt YAML, or a
|
|
196
|
+
* non-ollama execution role → null (callers skip with a hint).
|
|
197
|
+
*/
|
|
198
|
+
export function parseExecutionModel(
|
|
199
|
+
modelsYamlPath = join(homedir(), ".arkaos", "models.yaml"),
|
|
200
|
+
) {
|
|
201
|
+
try {
|
|
202
|
+
if (!existsSync(modelsYamlPath)) return null;
|
|
203
|
+
const doc = parseSimpleYaml(readFileSync(modelsYamlPath, "utf-8"));
|
|
204
|
+
const role = doc && doc.roles && doc.roles.execution;
|
|
205
|
+
if (!role || typeof role !== "object") return null;
|
|
206
|
+
const provider = typeof role.provider === "string" ? role.provider.trim() : "";
|
|
207
|
+
if (provider !== "ollama" && !provider.startsWith("ollama/")) return null;
|
|
208
|
+
let model = typeof role.model === "string" ? role.model.trim() : "";
|
|
209
|
+
// Embedded form "provider: ollama/<model>" (CLAUDE.md shorthand).
|
|
210
|
+
if (!model && provider.startsWith("ollama/")) {
|
|
211
|
+
model = provider.slice("ollama/".length);
|
|
212
|
+
}
|
|
213
|
+
// Alias slot (best/default/fast) → aliases.ollama lookup.
|
|
214
|
+
const aliases = doc.aliases && doc.aliases.ollama;
|
|
215
|
+
if (model && aliases && typeof aliases === "object" && typeof aliases[model] === "string") {
|
|
216
|
+
model = aliases[model].trim();
|
|
217
|
+
}
|
|
218
|
+
if (!model) return null;
|
|
219
|
+
return { model, effort: typeof role.effort === "string" ? role.effort : "" };
|
|
220
|
+
} catch {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ── Effects (real defaults; tests inject stubs) ───────────────────────────
|
|
226
|
+
|
|
227
|
+
// Model names come from the user's own config, but they end up on a
|
|
228
|
+
// command line — refuse anything outside the ollama name grammar.
|
|
229
|
+
const SAFE_MODEL_RE = /^[A-Za-z0-9][A-Za-z0-9._:/-]*$/;
|
|
230
|
+
|
|
231
|
+
export function defaultEffects() {
|
|
232
|
+
return {
|
|
233
|
+
pyImport(module) {
|
|
234
|
+
const py = getArkaosPython();
|
|
235
|
+
if (!py) return false;
|
|
236
|
+
try {
|
|
237
|
+
execSync(`"${py}" -c "import ${module}"`, { stdio: "ignore", timeout: 30000 });
|
|
238
|
+
return true;
|
|
239
|
+
} catch {
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
pipInstall(packages) {
|
|
244
|
+
return pipInstall(packages, { timeout: 300000, log: () => {} });
|
|
245
|
+
},
|
|
246
|
+
commandExists(bin) {
|
|
247
|
+
try {
|
|
248
|
+
execSync(`${CMD_FINDER} ${bin}`, { stdio: ["ignore", "ignore", "ignore"] });
|
|
249
|
+
return true;
|
|
250
|
+
} catch {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
fileExists(path) {
|
|
255
|
+
return existsSync(path);
|
|
256
|
+
},
|
|
257
|
+
checkOllama() {
|
|
258
|
+
return checkOllama();
|
|
259
|
+
},
|
|
260
|
+
ensureOllama() {
|
|
261
|
+
const sys = ensureSystemTools({ withOllama: true });
|
|
262
|
+
return sys.ollama || null;
|
|
263
|
+
},
|
|
264
|
+
detectManager() {
|
|
265
|
+
return detectPackageManager();
|
|
266
|
+
},
|
|
267
|
+
installSystemPackage(pkg, manager) {
|
|
268
|
+
return installViaPackageManager(pkg, { manager });
|
|
269
|
+
},
|
|
270
|
+
ollamaList() {
|
|
271
|
+
try {
|
|
272
|
+
return execSync("ollama list", {
|
|
273
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
274
|
+
timeout: 10000,
|
|
275
|
+
}).toString();
|
|
276
|
+
} catch {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
},
|
|
280
|
+
ollamaPull(model) {
|
|
281
|
+
try {
|
|
282
|
+
execSync(`ollama pull ${model}`, { stdio: "pipe", timeout: 1800000 });
|
|
283
|
+
return true;
|
|
284
|
+
} catch {
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
},
|
|
288
|
+
resolveExecutionModel() {
|
|
289
|
+
return parseExecutionModel();
|
|
290
|
+
},
|
|
291
|
+
graphifyInstalled() {
|
|
292
|
+
try {
|
|
293
|
+
return !!graphifyDoctor().installed;
|
|
294
|
+
} catch {
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
ensureGraphify() {
|
|
299
|
+
try {
|
|
300
|
+
return ensureGraphify();
|
|
301
|
+
} catch {
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
},
|
|
305
|
+
// Headless-safe default: no TTY conversation here — interactive
|
|
306
|
+
// callers (install wizard, future PRs) inject a real confirm.
|
|
307
|
+
async confirm() {
|
|
308
|
+
return false;
|
|
309
|
+
},
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ── Reconciliation ────────────────────────────────────────────────────────
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Bring the machine up to `profile`. Returns an ordered array of
|
|
317
|
+
* { id, label, status, hint? } with status ∈ present|installed|skipped|
|
|
318
|
+
* failed. Never throws for a single service (failure → status "failed"
|
|
319
|
+
* + continue); throws only when the manifest itself is unloadable.
|
|
320
|
+
*/
|
|
321
|
+
export async function reconcileServices({
|
|
322
|
+
profile,
|
|
323
|
+
repoRoot,
|
|
324
|
+
interactive = false,
|
|
325
|
+
effects = null,
|
|
326
|
+
log = () => {},
|
|
327
|
+
} = {}) {
|
|
328
|
+
const fx = { ...defaultEffects(), ...(effects || {}) };
|
|
329
|
+
const manifest = loadProfilesManifest(repoRoot);
|
|
330
|
+
const canonical = normalizeProfileFlag(profile) || DEFAULT_PROFILE;
|
|
331
|
+
const ids = resolveServicesForProfile(canonical, manifest);
|
|
332
|
+
const results = [];
|
|
333
|
+
const statusById = new Map();
|
|
334
|
+
for (const id of ids) {
|
|
335
|
+
const svc = manifest.services[id];
|
|
336
|
+
let result;
|
|
337
|
+
try {
|
|
338
|
+
result = await reconcileOne(id, svc, { fx, interactive, statusById });
|
|
339
|
+
} catch (err) {
|
|
340
|
+
// A single service must never abort the reconciliation run.
|
|
341
|
+
log(` ⚠ ${svc.label || id}: ${err.message}`);
|
|
342
|
+
result = { id, label: svc.label || id, status: "failed", hint: err.message };
|
|
343
|
+
}
|
|
344
|
+
statusById.set(id, result.status);
|
|
345
|
+
results.push(result);
|
|
346
|
+
}
|
|
347
|
+
return results;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function reconcileOne(id, svc, { fx, interactive, statusById }) {
|
|
351
|
+
const base = { id, label: svc.label || id };
|
|
352
|
+
|
|
353
|
+
// Dependency gate: a service whose prerequisite is not on the machine
|
|
354
|
+
// (skipped/failed this run) is skipped, never attempted blind.
|
|
355
|
+
for (const dep of svc.requires || []) {
|
|
356
|
+
const depStatus = statusById.get(dep);
|
|
357
|
+
if (depStatus && depStatus !== "present" && depStatus !== "installed") {
|
|
358
|
+
return { ...base, status: "skipped", hint: `requires ${dep} (${depStatus})` };
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
switch (svc.kind) {
|
|
363
|
+
case "pip": {
|
|
364
|
+
const modules = Array.isArray(svc.modules) ? svc.modules : [];
|
|
365
|
+
const present = modules.length > 0 && modules.every((m) => fx.pyImport(m));
|
|
366
|
+
if (present) return { ...base, status: "present" };
|
|
367
|
+
return fx.pipInstall(svc.packages)
|
|
368
|
+
? { ...base, status: "installed" }
|
|
369
|
+
: {
|
|
370
|
+
...base,
|
|
371
|
+
status: "failed",
|
|
372
|
+
hint: `run: ~/.arkaos/venv/bin/pip install '${svc.packages}'`,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
case "system-package": {
|
|
377
|
+
if (fx.commandExists(svc.binary)) return { ...base, status: "present" };
|
|
378
|
+
const manager = fx.detectManager();
|
|
379
|
+
const pkg = manager && svc.packages ? svc.packages[manager] : null;
|
|
380
|
+
const command = manager && pkg ? buildInstallCommand(manager, pkg) : null;
|
|
381
|
+
const hint = command ? `run: ${command}` : `install ${svc.binary} manually`;
|
|
382
|
+
// Consent gate: system packages touch the OS — never headless,
|
|
383
|
+
// never sudo, only on an explicit yes.
|
|
384
|
+
if (!interactive) return { ...base, status: "skipped", hint };
|
|
385
|
+
if (!manager || !pkg) return { ...base, status: "skipped", hint };
|
|
386
|
+
if (managerNeedsSudo(manager)) {
|
|
387
|
+
return { ...base, status: "skipped", hint: `needs sudo — ${hint}` };
|
|
388
|
+
}
|
|
389
|
+
const agreed = await fx.confirm(`Install ${base.label} now? (${command})`);
|
|
390
|
+
if (!agreed) return { ...base, status: "skipped", hint };
|
|
391
|
+
const r = fx.installSystemPackage(pkg, manager);
|
|
392
|
+
return r && r.installed
|
|
393
|
+
? { ...base, status: "installed" }
|
|
394
|
+
: { ...base, status: "failed", hint };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
case "ollama": {
|
|
398
|
+
const st = fx.checkOllama();
|
|
399
|
+
if (st && st.installed) {
|
|
400
|
+
// Installed but not running is still "present" — reconciliation
|
|
401
|
+
// installs software, it does not manage daemons.
|
|
402
|
+
return st.needsAction === "none"
|
|
403
|
+
? { ...base, status: "present" }
|
|
404
|
+
: { ...base, status: "present", hint: st.suggestedCommand || "not running — start Ollama" };
|
|
405
|
+
}
|
|
406
|
+
const hint = (st && st.suggestedCommand)
|
|
407
|
+
? `run: ${st.suggestedCommand}`
|
|
408
|
+
: "install from https://ollama.com/download";
|
|
409
|
+
if (!interactive) return { ...base, status: "skipped", hint };
|
|
410
|
+
if (st && st.needsSudo) return { ...base, status: "skipped", hint: `needs sudo — ${hint}` };
|
|
411
|
+
const agreed = await fx.confirm("Install Ollama (local LLM runtime) now?");
|
|
412
|
+
if (!agreed) return { ...base, status: "skipped", hint };
|
|
413
|
+
const tool = fx.ensureOllama();
|
|
414
|
+
return tool && tool.installed
|
|
415
|
+
? { ...base, status: "installed" }
|
|
416
|
+
: { ...base, status: "failed", hint };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
case "ollama-model": {
|
|
420
|
+
const resolved = fx.resolveExecutionModel();
|
|
421
|
+
if (!resolved || !resolved.model) {
|
|
422
|
+
return {
|
|
423
|
+
...base,
|
|
424
|
+
status: "skipped",
|
|
425
|
+
hint: "no ollama execution role in ~/.arkaos/models.yaml (npx arkaos models set execution ollama/<model>)",
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
const model = resolved.model;
|
|
429
|
+
const list = fx.ollamaList();
|
|
430
|
+
if (list === null) {
|
|
431
|
+
return { ...base, status: "skipped", hint: "ollama not reachable — start it and re-run" };
|
|
432
|
+
}
|
|
433
|
+
if (ollamaListHasModel(list, model)) return { ...base, status: "present" };
|
|
434
|
+
if (!SAFE_MODEL_RE.test(model)) {
|
|
435
|
+
return { ...base, status: "failed", hint: `refusing to pull unsafe model name "${model}"` };
|
|
436
|
+
}
|
|
437
|
+
return fx.ollamaPull(model)
|
|
438
|
+
? { ...base, status: "installed" }
|
|
439
|
+
: { ...base, status: "failed", hint: `run: ollama pull ${model}` };
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
case "file": {
|
|
443
|
+
const path = expandHome(svc.path);
|
|
444
|
+
return fx.fileExists(path)
|
|
445
|
+
? { ...base, status: "present" }
|
|
446
|
+
: { ...base, status: "failed", hint: svc.hint || `missing ${path}` };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
case "graphify": {
|
|
450
|
+
if (fx.graphifyInstalled()) return { ...base, status: "present" };
|
|
451
|
+
// ensureGraphify is the existing best-effort installer (uv/pipx,
|
|
452
|
+
// non-sudo) already run unconditionally by install/update — same
|
|
453
|
+
// consent posture here.
|
|
454
|
+
const g = fx.ensureGraphify();
|
|
455
|
+
const installed = !!(g && g.binary && g.binary.installed);
|
|
456
|
+
if (installed) return { ...base, status: "installed" };
|
|
457
|
+
return {
|
|
458
|
+
...base,
|
|
459
|
+
status: "skipped",
|
|
460
|
+
hint: (g && g.binary && g.binary.hint) || "run: uv tool install graphifyy",
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
default:
|
|
465
|
+
return { ...base, status: "skipped", hint: `unknown service kind "${svc.kind}"` };
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function expandHome(path) {
|
|
470
|
+
const p = String(path || "");
|
|
471
|
+
if (p === "~") return homedir();
|
|
472
|
+
if (p.startsWith("~/")) return join(homedir(), p.slice(2));
|
|
473
|
+
return p;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** Match a model against `ollama list` output (NAME first column). */
|
|
477
|
+
export function ollamaListHasModel(output, model) {
|
|
478
|
+
for (const line of String(output).split(/\r?\n/)) {
|
|
479
|
+
const token = line.trim().split(/\s+/)[0];
|
|
480
|
+
if (!token || token.toLowerCase() === "name") continue;
|
|
481
|
+
if (token === model) return true;
|
|
482
|
+
if (!model.includes(":")) {
|
|
483
|
+
if (token === `${model}:latest` || token.split(":")[0] === model) return true;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
return false;
|
|
487
|
+
}
|
package/installer/ui.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ArkaOS installer UI facade (Foundation PR-3).
|
|
3
|
+
*
|
|
4
|
+
* Single abstraction over the terminal output of install/update/prompts.
|
|
5
|
+
* Two render modes:
|
|
6
|
+
*
|
|
7
|
+
* fancy @clack/prompts + picocolors — branded intro, spinner per
|
|
8
|
+
* step, grouped notes, outro. Active ONLY when the optional
|
|
9
|
+
* dependencies load, stdout is a TTY, we are not in CI, and
|
|
10
|
+
* the user did not force plain via ARKA_UI_PLAIN=1.
|
|
11
|
+
*
|
|
12
|
+
* plain byte-identical to the historical installer output
|
|
13
|
+
* (` [n/total] msg`, ` ✓ msg`, ` ⚠ msg`,
|
|
14
|
+
* plain box banner) so headless logs, the auto-update daemon,
|
|
15
|
+
* and existing tests remain stable.
|
|
16
|
+
*
|
|
17
|
+
* House rule (.claude/rules/node-installer.md): graceful fallbacks when
|
|
18
|
+
* optional dependencies are unavailable — a missing @clack/prompts can
|
|
19
|
+
* NEVER throw; it degrades to plain.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
let cachedUi = null;
|
|
23
|
+
|
|
24
|
+
async function loadFancyDeps() {
|
|
25
|
+
try {
|
|
26
|
+
const clack = await import("@clack/prompts");
|
|
27
|
+
const pico = await import("picocolors");
|
|
28
|
+
const pc = pico.default ?? pico;
|
|
29
|
+
// Minimal shape check so a broken/partial install degrades to plain
|
|
30
|
+
// instead of crashing mid-wizard.
|
|
31
|
+
if (typeof clack.intro !== "function" || typeof clack.spinner !== "function") {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
return { clack, pc };
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolve the UI facade. Cached after the first call so install() and
|
|
42
|
+
* runSetupPrompts() share one instance (and one spinner state).
|
|
43
|
+
*/
|
|
44
|
+
export async function getUi() {
|
|
45
|
+
if (cachedUi) return cachedUi;
|
|
46
|
+
const deps = await loadFancyDeps();
|
|
47
|
+
const fancy =
|
|
48
|
+
Boolean(deps) &&
|
|
49
|
+
Boolean(process.stdout.isTTY) &&
|
|
50
|
+
!process.env.CI &&
|
|
51
|
+
process.env.ARKA_UI_PLAIN !== "1";
|
|
52
|
+
cachedUi = fancy ? makeFancyUi(deps) : makePlainUi();
|
|
53
|
+
return cachedUi;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Test hook — drop the cached facade so env changes take effect. */
|
|
57
|
+
export function resetUiCache() {
|
|
58
|
+
cachedUi = null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── Plain mode — the historical installer output, byte-identical ──────────
|
|
62
|
+
|
|
63
|
+
function makePlainUi() {
|
|
64
|
+
const step = (n, total, msg) => {
|
|
65
|
+
console.log(` [${n}/${total}] ${msg}`);
|
|
66
|
+
};
|
|
67
|
+
return {
|
|
68
|
+
isFancy: () => false,
|
|
69
|
+
clack: null,
|
|
70
|
+
colors: null,
|
|
71
|
+
intro(title) {
|
|
72
|
+
const inner = ` ${title}`.padEnd(54);
|
|
73
|
+
console.log(`
|
|
74
|
+
╔══════════════════════════════════════════════════════╗
|
|
75
|
+
║${inner}║
|
|
76
|
+
╚══════════════════════════════════════════════════════╝
|
|
77
|
+
`);
|
|
78
|
+
},
|
|
79
|
+
outro(msg) {
|
|
80
|
+
console.log(`\n ${msg}\n`);
|
|
81
|
+
},
|
|
82
|
+
note(body, title) {
|
|
83
|
+
if (title) console.log(`\n ── ${title} ──\n`);
|
|
84
|
+
console.log(
|
|
85
|
+
String(body)
|
|
86
|
+
.split("\n")
|
|
87
|
+
.map((l) => ` ${l}`)
|
|
88
|
+
.join("\n"),
|
|
89
|
+
);
|
|
90
|
+
},
|
|
91
|
+
step,
|
|
92
|
+
section: step,
|
|
93
|
+
ok(msg) {
|
|
94
|
+
console.log(` ✓ ${msg}`);
|
|
95
|
+
},
|
|
96
|
+
warn(msg) {
|
|
97
|
+
console.log(` ⚠ ${msg}`);
|
|
98
|
+
},
|
|
99
|
+
detail(msg) {
|
|
100
|
+
console.log(msg);
|
|
101
|
+
},
|
|
102
|
+
spinner() {
|
|
103
|
+
return {
|
|
104
|
+
start(msg) {
|
|
105
|
+
if (msg) console.log(msg);
|
|
106
|
+
},
|
|
107
|
+
stop(msg) {
|
|
108
|
+
if (msg) console.log(msg);
|
|
109
|
+
},
|
|
110
|
+
message() {},
|
|
111
|
+
};
|
|
112
|
+
},
|
|
113
|
+
stopSpinner() {},
|
|
114
|
+
warnings: () => [],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── Fancy mode — clack spinners, grouped sections, branded intro ──────────
|
|
119
|
+
|
|
120
|
+
function makeFancyUi({ clack, pc }) {
|
|
121
|
+
let active = null; // { s, title, okCount }
|
|
122
|
+
const warnings = [];
|
|
123
|
+
|
|
124
|
+
const finishActive = () => {
|
|
125
|
+
if (!active) return;
|
|
126
|
+
const suffix =
|
|
127
|
+
active.okCount > 0 ? pc.dim(` (${active.okCount} ok)`) : "";
|
|
128
|
+
active.s.stop(`${active.title}${suffix}`);
|
|
129
|
+
active = null;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const step = (n, total, msg) => {
|
|
133
|
+
finishActive();
|
|
134
|
+
const s = clack.spinner();
|
|
135
|
+
const title = `${pc.dim(`[${n}/${total}]`)} ${msg}`;
|
|
136
|
+
s.start(title);
|
|
137
|
+
active = { s, title, okCount: 0 };
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
isFancy: () => true,
|
|
142
|
+
clack,
|
|
143
|
+
colors: pc,
|
|
144
|
+
intro(title) {
|
|
145
|
+
clack.intro(pc.inverse(pc.bold(` ${title} `)));
|
|
146
|
+
},
|
|
147
|
+
outro(msg) {
|
|
148
|
+
finishActive();
|
|
149
|
+
clack.outro(msg);
|
|
150
|
+
},
|
|
151
|
+
note(body, title) {
|
|
152
|
+
finishActive();
|
|
153
|
+
clack.note(body, title);
|
|
154
|
+
},
|
|
155
|
+
step,
|
|
156
|
+
section: step,
|
|
157
|
+
ok(msg) {
|
|
158
|
+
if (active) {
|
|
159
|
+
active.okCount += 1;
|
|
160
|
+
active.s.message(`${active.title} ${pc.dim("·")} ${pc.green(msg)}`);
|
|
161
|
+
} else {
|
|
162
|
+
clack.log.success(msg);
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
warn(msg) {
|
|
166
|
+
warnings.push(msg);
|
|
167
|
+
if (active) {
|
|
168
|
+
active.s.message(`${active.title} ${pc.yellow(`⚠ ${msg}`)}`);
|
|
169
|
+
} else {
|
|
170
|
+
clack.log.warn(msg);
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
detail(msg) {
|
|
174
|
+
const t = String(msg).trim();
|
|
175
|
+
if (!t) return;
|
|
176
|
+
if (active) {
|
|
177
|
+
active.s.message(`${active.title} ${pc.dim(t)}`);
|
|
178
|
+
} else {
|
|
179
|
+
clack.log.message(pc.dim(t));
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
spinner() {
|
|
183
|
+
return clack.spinner();
|
|
184
|
+
},
|
|
185
|
+
stopSpinner() {
|
|
186
|
+
finishActive();
|
|
187
|
+
},
|
|
188
|
+
warnings: () => [...warnings],
|
|
189
|
+
};
|
|
190
|
+
}
|