arkaos 2.22.1 → 2.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +105 -9
- package/VERSION +1 -1
- package/arka/SKILL.md +1 -0
- package/arka/skills/comfyui/SKILL.md +2 -2
- package/arka/skills/comfyui/references/workflows.md +6 -6
- package/arka/skills/costs/SKILL.md +11 -0
- package/config/cognition/prompts/dreaming.md +3 -3
- package/config/cognition/prompts/research.md +4 -4
- package/config/hooks/user-prompt-submit.sh +6 -1
- package/core/cognition/__pycache__/auto_documentor.cpython-313.pyc +0 -0
- package/core/cognition/capture/__pycache__/collector.cpython-313.pyc +0 -0
- package/core/cognition/capture/collector.py +10 -3
- package/core/cognition/scheduler/__pycache__/daemon.cpython-313.pyc +0 -0
- package/core/cognition/scheduler/daemon.py +5 -0
- package/core/jobs/__pycache__/auto_doc_worker.cpython-313.pyc +0 -0
- package/core/obsidian/__pycache__/writer.cpython-313.pyc +0 -0
- package/core/obsidian/writer.py +5 -4
- package/core/runtime/__pycache__/base.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/claude_code.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/codex_cli.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/gemini_cli.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/llm_provider.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/path_resolver.cpython-313.pyc +0 -0
- package/core/runtime/path_resolver.py +202 -0
- package/core/shared/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/shared/__pycache__/safe_session_id.cpython-313.pyc +0 -0
- package/core/specs/SPEC-installer-cross-os.md +227 -0
- package/core/specs/SPEC-paths-portability.md +209 -0
- package/core/synapse/__pycache__/kb_cache.cpython-313.pyc +0 -0
- package/core/synapse/__pycache__/layers.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/flow_enforcer.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/marker_cache.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/research_gate.cpython-313.pyc +0 -0
- package/departments/dev/skills/scaffold/SKILL.md +4 -4
- package/departments/kb/skills/knowledge/SKILL.md +1 -1
- package/departments/ops/skills/operations/SKILL.md +1 -1
- package/installer/cli.js +2 -1
- package/installer/doctor.js +48 -0
- package/installer/index.js +26 -1
- package/installer/migrate-user-data.js +17 -1
- package/installer/migrations/v3_path_schema.js +109 -0
- package/installer/package-manager.js +191 -0
- package/installer/path-resolver.js +124 -0
- package/installer/system-tools.js +243 -0
- package/installer/update.js +24 -3
- package/knowledge/obsidian-config.json +1 -1
- package/package.json +1 -1
- package/pyproject.toml +1 -1
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration: profile.json schema v2 → v3 (paths-portability, v2.23.0).
|
|
3
|
+
*
|
|
4
|
+
* Adds the structured `projectRoots: string[]` and `reposRoot: string`
|
|
5
|
+
* fields to every existing profile.json so the new path_resolver token
|
|
6
|
+
* substitution can run for the 20K-user installed base.
|
|
7
|
+
*
|
|
8
|
+
* Strategy:
|
|
9
|
+
* 1. Skip if `version === "3"` or `projectRoots` already present (idempotent).
|
|
10
|
+
* 2. Otherwise:
|
|
11
|
+
* - Write `.bak-<unix-timestamp>` backup beside the profile.
|
|
12
|
+
* - Parse `projectsDir` free-text to extract absolute paths.
|
|
13
|
+
* - Default to ["~/Herd","~/Work","~/AIProjects"] if parsing returns 0.
|
|
14
|
+
* - reposRoot = first match containing "AIProjects", else "~/AIProjects".
|
|
15
|
+
* - Atomic write (temp file + rename) with version: "3" and migrated_at.
|
|
16
|
+
* - Log to ~/.arkaos/logs/migrate.log.
|
|
17
|
+
*
|
|
18
|
+
* Safe for concurrent invocations: the backup filename includes the
|
|
19
|
+
* timestamp, atomic rename prevents partial writes, and the idempotent
|
|
20
|
+
* guard means a second run is a no-op.
|
|
21
|
+
*
|
|
22
|
+
* Contract documented in core/specs/SPEC-paths-portability.md (PR1 v2.23.0).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
appendFileSync,
|
|
27
|
+
existsSync,
|
|
28
|
+
mkdirSync,
|
|
29
|
+
readFileSync,
|
|
30
|
+
renameSync,
|
|
31
|
+
writeFileSync,
|
|
32
|
+
} from "node:fs";
|
|
33
|
+
import { homedir } from "node:os";
|
|
34
|
+
import { dirname, join } from "node:path";
|
|
35
|
+
|
|
36
|
+
const DEFAULT_PROJECT_ROOTS = ["~/Herd", "~/Work", "~/AIProjects"];
|
|
37
|
+
const DEFAULT_REPOS_ROOT = "~/AIProjects";
|
|
38
|
+
|
|
39
|
+
export function migrateProfileSchemaV3({ profilePath, logPath } = {}) {
|
|
40
|
+
const profile = profilePath || join(homedir(), ".arkaos", "profile.json");
|
|
41
|
+
const log = logPath || join(homedir(), ".arkaos", "logs", "migrate.log");
|
|
42
|
+
|
|
43
|
+
if (!existsSync(profile)) {
|
|
44
|
+
return { skipped: true, reason: "profile.json absent" };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let raw;
|
|
48
|
+
try {
|
|
49
|
+
raw = JSON.parse(readFileSync(profile, "utf8"));
|
|
50
|
+
} catch (err) {
|
|
51
|
+
return { skipped: true, reason: `parse error: ${err.message}` };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (raw.version === "3" || Array.isArray(raw.projectRoots)) {
|
|
55
|
+
return { skipped: true, reason: "already migrated" };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const projectRoots = parseProjectsDirText(raw.projectsDir || "");
|
|
59
|
+
const reposRoot =
|
|
60
|
+
projectRoots.find((r) => r.includes("AIProjects")) || DEFAULT_REPOS_ROOT;
|
|
61
|
+
|
|
62
|
+
const migrated = {
|
|
63
|
+
...raw,
|
|
64
|
+
version: "3",
|
|
65
|
+
projectRoots,
|
|
66
|
+
reposRoot,
|
|
67
|
+
migrated_at: new Date().toISOString(),
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const backup = `${profile}.bak-${Math.floor(Date.now() / 1000)}`;
|
|
71
|
+
writeFileSync(backup, readFileSync(profile, "utf8"));
|
|
72
|
+
|
|
73
|
+
const tmp = `${profile}.tmp`;
|
|
74
|
+
writeFileSync(tmp, JSON.stringify(migrated, null, 2));
|
|
75
|
+
renameSync(tmp, profile);
|
|
76
|
+
|
|
77
|
+
writeLog(log, projectRoots, reposRoot);
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
migrated: true,
|
|
81
|
+
projectRoots,
|
|
82
|
+
reposRoot,
|
|
83
|
+
backup,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function parseProjectsDirText(text) {
|
|
88
|
+
if (!text) return [...DEFAULT_PROJECT_ROOTS];
|
|
89
|
+
const posix = /(?:\/Users|\/home)\/\S+?\/(?:Herd|Work|AIProjects|code|repos)/g;
|
|
90
|
+
const windows = /[A-Z]:\\Users\\[^\s\\]+\\(?:Herd|Work|AIProjects|code|repos)/g;
|
|
91
|
+
const found = [
|
|
92
|
+
...(text.match(posix) || []),
|
|
93
|
+
...(text.match(windows) || []),
|
|
94
|
+
];
|
|
95
|
+
return found.length > 0 ? found : [...DEFAULT_PROJECT_ROOTS];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function writeLog(logPath, projectRoots, reposRoot) {
|
|
99
|
+
try {
|
|
100
|
+
mkdirSync(dirname(logPath), { recursive: true });
|
|
101
|
+
const line =
|
|
102
|
+
`[arka:migrated] ${new Date().toISOString()} ` +
|
|
103
|
+
`profile.json schema v2 → v3 (` +
|
|
104
|
+
`${projectRoots.length} roots, reposRoot=${reposRoot})\n`;
|
|
105
|
+
appendFileSync(logPath, line);
|
|
106
|
+
} catch {
|
|
107
|
+
// Log failure should never block migration.
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-OS package manager abstraction for the ArkaOS installer.
|
|
3
|
+
*
|
|
4
|
+
* Centralises detection of brew (macOS), winget/choco (Windows) and
|
|
5
|
+
* apt/snap (Linux) so install/update flows can either run a non-sudo
|
|
6
|
+
* install themselves or return a copy-paste command for the user.
|
|
7
|
+
*
|
|
8
|
+
* **Never invokes sudo automatically.** When sudo is required (apt /
|
|
9
|
+
* snap on Linux, winget without elevation), `installViaPackageManager`
|
|
10
|
+
* returns `{ needsSudo: true, command }` and the caller is expected
|
|
11
|
+
* to print the command for the user to run. This keeps the installer
|
|
12
|
+
* headless-safe — it must not surprise a CI job with a password prompt.
|
|
13
|
+
*
|
|
14
|
+
* See core/specs/SPEC-installer-cross-os.md (PR2 v2.24.0).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { execSync } from "node:child_process";
|
|
18
|
+
import { platform } from "node:os";
|
|
19
|
+
import { IS_WINDOWS } from "./platform.js";
|
|
20
|
+
|
|
21
|
+
const MAC = "darwin";
|
|
22
|
+
const LINUX = "linux";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Detect the highest-priority package manager available on this host.
|
|
26
|
+
*
|
|
27
|
+
* Priority order:
|
|
28
|
+
* macOS: brew
|
|
29
|
+
* Linux: apt > snap (apt for generic packages; snap preferred for
|
|
30
|
+
* Obsidian because its tarball is classic-confined)
|
|
31
|
+
* Windows: winget > choco
|
|
32
|
+
*
|
|
33
|
+
* Returns the canonical name string or null when nothing is available.
|
|
34
|
+
*/
|
|
35
|
+
export function detectPackageManager() {
|
|
36
|
+
const candidates = preferredCandidates();
|
|
37
|
+
for (const name of candidates) {
|
|
38
|
+
if (hasCommand(name)) return name;
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Return every package manager available on this host, in priority order.
|
|
45
|
+
* Useful for callers that need a different manager per package (e.g.
|
|
46
|
+
* snap for Obsidian on a host that also has apt for Node).
|
|
47
|
+
*/
|
|
48
|
+
export function detectAllPackageManagers() {
|
|
49
|
+
return preferredCandidates().filter((name) => hasCommand(name));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Build the install command for a package on a given manager, without
|
|
54
|
+
* executing it. Useful for `formatSudoInstructions` and for dry-run paths.
|
|
55
|
+
*/
|
|
56
|
+
export function buildInstallCommand(manager, pkg) {
|
|
57
|
+
switch (manager) {
|
|
58
|
+
case "brew":
|
|
59
|
+
return pkg.startsWith("cask:")
|
|
60
|
+
? `brew install --cask ${pkg.slice(5)}`
|
|
61
|
+
: `brew install ${pkg}`;
|
|
62
|
+
case "apt":
|
|
63
|
+
return `sudo apt update && sudo apt install -y ${pkg}`;
|
|
64
|
+
case "snap":
|
|
65
|
+
return pkg.endsWith(":classic")
|
|
66
|
+
? `sudo snap install ${pkg.slice(0, -8)} --classic`
|
|
67
|
+
: `sudo snap install ${pkg}`;
|
|
68
|
+
case "winget":
|
|
69
|
+
return `winget install --id ${pkg} --silent --accept-source-agreements --accept-package-agreements`;
|
|
70
|
+
case "choco":
|
|
71
|
+
return `choco install ${pkg} -y`;
|
|
72
|
+
default:
|
|
73
|
+
return "";
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Whether a manager needs sudo (or Windows elevation) for install.
|
|
79
|
+
* brew on macOS is intentionally not sudo-gated (Homebrew runs as the user).
|
|
80
|
+
*/
|
|
81
|
+
export function managerNeedsSudo(manager) {
|
|
82
|
+
return manager === "apt" || manager === "snap";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Attempt a non-sudo install. When the chosen manager requires sudo
|
|
87
|
+
* we never invoke it ourselves — the caller surfaces the command to
|
|
88
|
+
* the user. Returns a structured result the caller can react to.
|
|
89
|
+
*/
|
|
90
|
+
export function installViaPackageManager(pkg, options = {}) {
|
|
91
|
+
const manager = options.manager || detectPackageManager();
|
|
92
|
+
if (!manager) {
|
|
93
|
+
return {
|
|
94
|
+
success: false,
|
|
95
|
+
manager: null,
|
|
96
|
+
command: "",
|
|
97
|
+
needsSudo: false,
|
|
98
|
+
installed: false,
|
|
99
|
+
error: "no_package_manager",
|
|
100
|
+
fallbackUrl: options.fallbackUrl || null,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const command = buildInstallCommand(manager, pkg);
|
|
105
|
+
const needsSudo = managerNeedsSudo(manager);
|
|
106
|
+
|
|
107
|
+
if (needsSudo) {
|
|
108
|
+
return {
|
|
109
|
+
success: true,
|
|
110
|
+
manager,
|
|
111
|
+
command,
|
|
112
|
+
needsSudo: true,
|
|
113
|
+
installed: false,
|
|
114
|
+
error: null,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (options.dryRun) {
|
|
119
|
+
return {
|
|
120
|
+
success: true,
|
|
121
|
+
manager,
|
|
122
|
+
command,
|
|
123
|
+
needsSudo: false,
|
|
124
|
+
installed: false,
|
|
125
|
+
error: null,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
execSync(command, { stdio: "inherit" });
|
|
131
|
+
return {
|
|
132
|
+
success: true,
|
|
133
|
+
manager,
|
|
134
|
+
command,
|
|
135
|
+
needsSudo: false,
|
|
136
|
+
installed: true,
|
|
137
|
+
error: null,
|
|
138
|
+
};
|
|
139
|
+
} catch (err) {
|
|
140
|
+
return {
|
|
141
|
+
success: false,
|
|
142
|
+
manager,
|
|
143
|
+
command,
|
|
144
|
+
needsSudo: false,
|
|
145
|
+
installed: false,
|
|
146
|
+
error: err?.message || "install_failed",
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Format an array of sudo install commands into a copy-paste block.
|
|
153
|
+
* Used by ensureSystemTools to print a single, friendly block when
|
|
154
|
+
* several packages need privileged install on Linux.
|
|
155
|
+
*/
|
|
156
|
+
export function formatSudoInstructions(commands) {
|
|
157
|
+
if (!commands || commands.length === 0) return "";
|
|
158
|
+
const unique = [...new Set(commands.filter(Boolean))];
|
|
159
|
+
if (unique.length === 0) return "";
|
|
160
|
+
const lines = [
|
|
161
|
+
"",
|
|
162
|
+
" To finish setup, please run these commands:",
|
|
163
|
+
"",
|
|
164
|
+
...unique.map((c) => ` ${c}`),
|
|
165
|
+
"",
|
|
166
|
+
" Then re-run: npx arkaos install",
|
|
167
|
+
"",
|
|
168
|
+
];
|
|
169
|
+
return lines.join("\n");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function preferredCandidates() {
|
|
173
|
+
const os = platform();
|
|
174
|
+
if (os === MAC) return ["brew"];
|
|
175
|
+
if (os === LINUX) return ["apt", "snap"];
|
|
176
|
+
if (IS_WINDOWS) return ["winget", "choco"];
|
|
177
|
+
return [];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function hasCommand(name) {
|
|
181
|
+
const finder = IS_WINDOWS ? "where" : "command -v";
|
|
182
|
+
try {
|
|
183
|
+
execSync(`${finder} ${name}`, {
|
|
184
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
185
|
+
shell: !IS_WINDOWS,
|
|
186
|
+
});
|
|
187
|
+
return true;
|
|
188
|
+
} catch {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path template resolver for the ArkaOS installer (Node.js side).
|
|
3
|
+
*
|
|
4
|
+
* Mirrors core/runtime/path_resolver.py just enough to substitute
|
|
5
|
+
* ${VAULT_PATH}, ${ARKA_OS_REPOS}, ${PROJECT_ROOTS}, ${GIT_HOST}, ${HOME}
|
|
6
|
+
* inside SKILL.md, cognition prompts and other markdown that the installer
|
|
7
|
+
* copies to the user's ~/.claude/skills/arka/ tree or ~/.arkaos/.
|
|
8
|
+
*
|
|
9
|
+
* Source files contain templates so the arka-os repo stays portable across
|
|
10
|
+
* the 20K user base. The installer rewrites tokens at copy-time so each
|
|
11
|
+
* user's installation has concrete paths.
|
|
12
|
+
*
|
|
13
|
+
* The Python module is the single source of truth for the substitution
|
|
14
|
+
* contract. Keep this file in sync. See core/specs/SPEC-paths-portability.md.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
|
|
21
|
+
const TOKEN_PATTERN = /\$\{([A-Z_]+)\}/g;
|
|
22
|
+
const DEFAULT_PROJECT_ROOTS = ["~/Herd", "~/Work", "~/AIProjects"];
|
|
23
|
+
const DEFAULT_REPOS_ROOT = "~/AIProjects";
|
|
24
|
+
const DEFAULT_GIT_HOST = "github.com";
|
|
25
|
+
|
|
26
|
+
let _profileCache = null;
|
|
27
|
+
|
|
28
|
+
function profilePath() {
|
|
29
|
+
return join(homedir(), ".arkaos", "profile.json");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function loadProfile({ refresh = false } = {}) {
|
|
33
|
+
if (_profileCache && !refresh) return _profileCache;
|
|
34
|
+
|
|
35
|
+
const path = profilePath();
|
|
36
|
+
if (!existsSync(path)) {
|
|
37
|
+
// Installer can be invoked before profile.json exists (first install).
|
|
38
|
+
// Caller decides whether to treat that as fatal.
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
let raw;
|
|
42
|
+
try {
|
|
43
|
+
raw = JSON.parse(readFileSync(path, "utf8"));
|
|
44
|
+
} catch (err) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`~/.arkaos/profile.json could not be parsed (${err.message}). ` +
|
|
47
|
+
`Run /arka setup to repair, or restore from a .bak file.`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const vaultPath = raw.vaultPath || raw.vault_path || "";
|
|
52
|
+
const reposRoot = raw.reposRoot || raw.repos_root || DEFAULT_REPOS_ROOT;
|
|
53
|
+
const projectRoots = Array.isArray(raw.projectRoots) && raw.projectRoots.length
|
|
54
|
+
? raw.projectRoots
|
|
55
|
+
: deriveProjectRoots(raw.projectsDir || "");
|
|
56
|
+
|
|
57
|
+
_profileCache = {
|
|
58
|
+
version: String(raw.version || "2"),
|
|
59
|
+
vaultPath,
|
|
60
|
+
reposRoot,
|
|
61
|
+
projectRoots,
|
|
62
|
+
raw,
|
|
63
|
+
};
|
|
64
|
+
return _profileCache;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function resetCache() {
|
|
68
|
+
_profileCache = null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function resolveString(template, options = {}) {
|
|
72
|
+
if (typeof template !== "string" || !template.includes("${")) return template;
|
|
73
|
+
const profile = options.profile || loadProfile();
|
|
74
|
+
return template.replace(TOKEN_PATTERN, (match, name) =>
|
|
75
|
+
resolveToken(name, match, profile)
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function resolveFile(srcPath, dstPath, options = {}) {
|
|
80
|
+
const content = readFileSync(srcPath, "utf8");
|
|
81
|
+
const resolved = resolveString(content, options);
|
|
82
|
+
writeFileSync(dstPath, resolved, "utf8");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function resolveToken(name, original, profile) {
|
|
86
|
+
if (name === "HOME") return homedir();
|
|
87
|
+
if (name === "GIT_HOST") {
|
|
88
|
+
return envOr("ARKAOS_GIT_HOST", DEFAULT_GIT_HOST);
|
|
89
|
+
}
|
|
90
|
+
if (name === "VAULT_PATH") {
|
|
91
|
+
const envValue = envOr("ARKAOS_VAULT_PATH", envOr("ARKAOS_VAULT", ""));
|
|
92
|
+
if (envValue) return envValue;
|
|
93
|
+
return profile?.vaultPath || original;
|
|
94
|
+
}
|
|
95
|
+
if (name === "ARKA_OS_REPOS") {
|
|
96
|
+
const envValue = envOr("ARKAOS_REPOS_ROOT", "");
|
|
97
|
+
if (envValue) return envValue;
|
|
98
|
+
return profile?.reposRoot || original;
|
|
99
|
+
}
|
|
100
|
+
if (name === "PROJECT_ROOTS") {
|
|
101
|
+
const envValue = envOr("ARKAOS_PROJECT_ROOTS", "");
|
|
102
|
+
if (envValue) return envValue;
|
|
103
|
+
const roots = profile?.projectRoots || DEFAULT_PROJECT_ROOTS;
|
|
104
|
+
const sep = process.platform === "win32" ? ";" : ":";
|
|
105
|
+
return roots.join(sep);
|
|
106
|
+
}
|
|
107
|
+
return original;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function envOr(name, fallback) {
|
|
111
|
+
const value = process.env[name];
|
|
112
|
+
return value && value.length > 0 ? value : fallback;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function deriveProjectRoots(projectsDirText) {
|
|
116
|
+
if (!projectsDirText) return [...DEFAULT_PROJECT_ROOTS];
|
|
117
|
+
const posix = /(?:\/Users|\/home)\/\S+?\/(?:Herd|Work|AIProjects|code|repos)/g;
|
|
118
|
+
const windows = /[A-Z]:\\Users\\[^\s\\]+\\(?:Herd|Work|AIProjects|code|repos)/g;
|
|
119
|
+
const found = [
|
|
120
|
+
...(projectsDirText.match(posix) || []),
|
|
121
|
+
...(projectsDirText.match(windows) || []),
|
|
122
|
+
];
|
|
123
|
+
return found.length > 0 ? found : [...DEFAULT_PROJECT_ROOTS];
|
|
124
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-OS system-tool guarantor for the ArkaOS installer.
|
|
3
|
+
*
|
|
4
|
+
* Validates that the three external dependencies ArkaOS needs at runtime
|
|
5
|
+
* — Obsidian, Node.js ≥ 20, Python ≥ 3.12 — are present. Tools that can
|
|
6
|
+
* be installed without sudo (brew on macOS, winget on Windows) are
|
|
7
|
+
* installed automatically. Tools that need sudo (apt/snap on Linux) get
|
|
8
|
+
* a copy-paste command surfaced via `sudoCommands`.
|
|
9
|
+
*
|
|
10
|
+
* Idempotent: tools already at an acceptable version are no-ops.
|
|
11
|
+
*
|
|
12
|
+
* Spec: core/specs/SPEC-installer-cross-os.md (PR2 v2.24.0).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { execSync } from "node:child_process";
|
|
16
|
+
import { existsSync } from "node:fs";
|
|
17
|
+
import { platform } from "node:os";
|
|
18
|
+
import { IS_WINDOWS, CMD_FINDER } from "./platform.js";
|
|
19
|
+
import {
|
|
20
|
+
buildInstallCommand,
|
|
21
|
+
detectAllPackageManagers,
|
|
22
|
+
detectPackageManager,
|
|
23
|
+
installViaPackageManager,
|
|
24
|
+
managerNeedsSudo,
|
|
25
|
+
} from "./package-manager.js";
|
|
26
|
+
|
|
27
|
+
const NODE_MIN_MAJOR = 20;
|
|
28
|
+
const PYTHON_MIN = { major: 3, minor: 12 };
|
|
29
|
+
|
|
30
|
+
const OBSIDIAN_PACKAGE = {
|
|
31
|
+
brew: "cask:obsidian",
|
|
32
|
+
snap: "obsidian:classic",
|
|
33
|
+
winget: "Obsidian.Obsidian",
|
|
34
|
+
choco: "obsidian",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const NODE_PACKAGE = {
|
|
38
|
+
brew: "node",
|
|
39
|
+
apt: "nodejs",
|
|
40
|
+
winget: "OpenJS.NodeJS",
|
|
41
|
+
choco: "nodejs",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const OBSIDIAN_FALLBACK_URL = "https://obsidian.md/download";
|
|
45
|
+
const NODE_FALLBACK_URL = "https://nodejs.org/en/download";
|
|
46
|
+
const PYTHON_FALLBACK_URL = "https://www.python.org/downloads/";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Validate every required tool, install what can be installed without
|
|
50
|
+
* sudo, and collect copy-paste commands for the ones that can't.
|
|
51
|
+
*/
|
|
52
|
+
export function ensureSystemTools(options = {}) {
|
|
53
|
+
if (options.skipSystem) {
|
|
54
|
+
return {
|
|
55
|
+
skipped: true,
|
|
56
|
+
obsidian: null,
|
|
57
|
+
node: null,
|
|
58
|
+
python: null,
|
|
59
|
+
sudoCommands: [],
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const obsidian = ensureTool("obsidian", checkObsidian, OBSIDIAN_PACKAGE, OBSIDIAN_FALLBACK_URL, options);
|
|
64
|
+
const node = ensureTool("node", checkNode, NODE_PACKAGE, NODE_FALLBACK_URL, options);
|
|
65
|
+
const python = checkPython(); // never auto-install Python — leave to OS
|
|
66
|
+
|
|
67
|
+
const sudoCommands = [obsidian, node]
|
|
68
|
+
.filter((t) => t?.needsSudo && t.suggestedCommand)
|
|
69
|
+
.map((t) => t.suggestedCommand);
|
|
70
|
+
|
|
71
|
+
if (python.needsAction !== "none" && python.suggestedCommand) {
|
|
72
|
+
sudoCommands.push(python.suggestedCommand);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { skipped: false, obsidian, node, python, sudoCommands };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Cheap presence + version check for Obsidian. Looks for the binary
|
|
80
|
+
* via OS-native finder and falls back to known install paths.
|
|
81
|
+
*/
|
|
82
|
+
export function checkObsidian() {
|
|
83
|
+
const location = findBinary("obsidian") || findObsidianAppPath();
|
|
84
|
+
if (location) {
|
|
85
|
+
return {
|
|
86
|
+
name: "obsidian",
|
|
87
|
+
installed: true,
|
|
88
|
+
location,
|
|
89
|
+
needsAction: "none",
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
const suggested = buildSuggestedInstall(OBSIDIAN_PACKAGE, OBSIDIAN_FALLBACK_URL);
|
|
93
|
+
return {
|
|
94
|
+
name: "obsidian",
|
|
95
|
+
installed: false,
|
|
96
|
+
needsAction: "install",
|
|
97
|
+
suggestedCommand: suggested.command,
|
|
98
|
+
needsSudo: suggested.needsSudo,
|
|
99
|
+
fallbackUrl: suggested.fallbackUrl,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function checkNode() {
|
|
104
|
+
const location = findBinary("node");
|
|
105
|
+
if (!location) {
|
|
106
|
+
const suggested = buildSuggestedInstall(NODE_PACKAGE, NODE_FALLBACK_URL);
|
|
107
|
+
return {
|
|
108
|
+
name: "node",
|
|
109
|
+
installed: false,
|
|
110
|
+
needsAction: "install",
|
|
111
|
+
suggestedCommand: suggested.command,
|
|
112
|
+
needsSudo: suggested.needsSudo,
|
|
113
|
+
fallbackUrl: suggested.fallbackUrl,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
const version = readNodeVersion();
|
|
117
|
+
const major = version ? Number(version.split(".")[0]) : 0;
|
|
118
|
+
if (major >= NODE_MIN_MAJOR) {
|
|
119
|
+
return { name: "node", installed: true, location, version, needsAction: "none" };
|
|
120
|
+
}
|
|
121
|
+
const suggested = buildSuggestedInstall(NODE_PACKAGE, NODE_FALLBACK_URL);
|
|
122
|
+
return {
|
|
123
|
+
name: "node",
|
|
124
|
+
installed: true,
|
|
125
|
+
location,
|
|
126
|
+
version,
|
|
127
|
+
needsAction: "upgrade",
|
|
128
|
+
suggestedCommand: suggested.command,
|
|
129
|
+
needsSudo: suggested.needsSudo,
|
|
130
|
+
fallbackUrl: suggested.fallbackUrl,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function checkPython() {
|
|
135
|
+
const candidates = IS_WINDOWS ? ["python", "py"] : ["python3", "python3.12", "python3.13"];
|
|
136
|
+
for (const cmd of candidates) {
|
|
137
|
+
const location = findBinary(cmd);
|
|
138
|
+
if (!location) continue;
|
|
139
|
+
const version = readVersion(`${cmd} --version`);
|
|
140
|
+
const ok = versionAtLeast(version, PYTHON_MIN.major, PYTHON_MIN.minor);
|
|
141
|
+
if (ok) {
|
|
142
|
+
return { name: "python", installed: true, location, version, needsAction: "none" };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
name: "python",
|
|
147
|
+
installed: false,
|
|
148
|
+
needsAction: "install",
|
|
149
|
+
suggestedCommand: `Install Python ${PYTHON_MIN.major}.${PYTHON_MIN.minor}+ from ${PYTHON_FALLBACK_URL}`,
|
|
150
|
+
needsSudo: false,
|
|
151
|
+
fallbackUrl: PYTHON_FALLBACK_URL,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function ensureTool(name, checkFn, packageMap, fallbackUrl, options) {
|
|
156
|
+
const status = checkFn();
|
|
157
|
+
if (status.needsAction === "none") return status;
|
|
158
|
+
|
|
159
|
+
const manager = detectPackageManager();
|
|
160
|
+
if (!manager || !packageMap[manager]) {
|
|
161
|
+
return { ...status, fallbackUrl };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (managerNeedsSudo(manager)) {
|
|
165
|
+
return {
|
|
166
|
+
...status,
|
|
167
|
+
suggestedCommand: buildInstallCommand(manager, packageMap[manager]),
|
|
168
|
+
needsSudo: true,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (options.dryRun) return status;
|
|
173
|
+
|
|
174
|
+
const result = installViaPackageManager(packageMap[manager], {
|
|
175
|
+
manager,
|
|
176
|
+
fallbackUrl,
|
|
177
|
+
});
|
|
178
|
+
if (result.installed) {
|
|
179
|
+
return { ...checkFn(), justInstalled: true };
|
|
180
|
+
}
|
|
181
|
+
return { ...status, error: result.error };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function buildSuggestedInstall(packageMap, fallbackUrl) {
|
|
185
|
+
const managers = detectAllPackageManagers();
|
|
186
|
+
for (const m of managers) {
|
|
187
|
+
if (packageMap[m]) {
|
|
188
|
+
return {
|
|
189
|
+
command: buildInstallCommand(m, packageMap[m]),
|
|
190
|
+
needsSudo: managerNeedsSudo(m),
|
|
191
|
+
fallbackUrl,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return { command: `Download from ${fallbackUrl}`, needsSudo: false, fallbackUrl };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function findBinary(name) {
|
|
199
|
+
try {
|
|
200
|
+
const out = execSync(`${CMD_FINDER} ${name}`, {
|
|
201
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
202
|
+
}).toString().trim().split(/\r?\n/)[0];
|
|
203
|
+
return out || null;
|
|
204
|
+
} catch {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function findObsidianAppPath() {
|
|
210
|
+
if (platform() === "darwin") {
|
|
211
|
+
const mac = "/Applications/Obsidian.app";
|
|
212
|
+
if (existsSync(mac)) return mac;
|
|
213
|
+
}
|
|
214
|
+
if (IS_WINDOWS) {
|
|
215
|
+
const localApp = process.env.LOCALAPPDATA;
|
|
216
|
+
if (localApp) {
|
|
217
|
+
const candidate = `${localApp}\\Obsidian\\Obsidian.exe`;
|
|
218
|
+
if (existsSync(candidate)) return candidate;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function readNodeVersion() {
|
|
225
|
+
return readVersion("node --version");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function readVersion(command) {
|
|
229
|
+
try {
|
|
230
|
+
const out = execSync(command, { stdio: ["ignore", "pipe", "ignore"] }).toString();
|
|
231
|
+
const match = out.match(/(\d+\.\d+(?:\.\d+)?)/);
|
|
232
|
+
return match ? match[1] : null;
|
|
233
|
+
} catch {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function versionAtLeast(version, major, minor) {
|
|
239
|
+
if (!version) return false;
|
|
240
|
+
const parts = version.split(".").map((n) => Number(n) || 0);
|
|
241
|
+
if (parts[0] !== major) return parts[0] > major;
|
|
242
|
+
return parts[1] >= minor;
|
|
243
|
+
}
|