create-ekka-desktop-app 0.2.2 → 0.3.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/bin/cli.js +231 -50
- package/package.json +1 -1
- package/template/app.config.json +14 -0
- package/template/src-tauri/Cargo.toml +1 -0
- package/template/src-tauri/build.rs +78 -0
- package/template/src-tauri/src/bootstrap.rs +5 -4
- package/template/src-tauri/src/commands.rs +16 -11
- package/template/src-tauri/src/config.rs +35 -0
- package/template/src-tauri/src/engine_process.rs +3 -3
- package/template/src-tauri/src/main.rs +14 -4
- package/template/src-tauri/src/node_auth.rs +13 -9
- package/template/src-tauri/src/node_credentials.rs +2 -1
- package/template/src-tauri/src/node_runner.rs +3 -2
- package/template/src-tauri/src/ops/home.rs +5 -5
- package/template/src-tauri/src/state.rs +12 -11
package/bin/cli.js
CHANGED
|
@@ -3,70 +3,251 @@
|
|
|
3
3
|
import { existsSync, mkdirSync, cpSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { resolve, join, dirname } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { createInterface } from 'node:readline';
|
|
6
7
|
|
|
7
8
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
9
|
const templateDir = resolve(__dirname, '..', 'template');
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
// Parse command line arguments
|
|
12
|
+
const args = process.argv.slice(2);
|
|
13
|
+
let projectName = null;
|
|
14
|
+
let configFile = null;
|
|
15
|
+
let appName = null;
|
|
16
|
+
let appSlug = null;
|
|
17
|
+
let engineUrl = null;
|
|
18
|
+
let orgPrefix = 'ai.ekka';
|
|
19
|
+
|
|
20
|
+
for (let i = 0; i < args.length; i++) {
|
|
21
|
+
const arg = args[i];
|
|
22
|
+
if (arg === '--config' || arg === '-c') {
|
|
23
|
+
configFile = args[++i];
|
|
24
|
+
} else if (arg === '--name' || arg === '-n') {
|
|
25
|
+
appName = args[++i];
|
|
26
|
+
} else if (arg === '--slug' || arg === '-s') {
|
|
27
|
+
appSlug = args[++i];
|
|
28
|
+
} else if (arg === '--engine-url' || arg === '-e') {
|
|
29
|
+
engineUrl = args[++i];
|
|
30
|
+
} else if (arg === '--org' || arg === '-o') {
|
|
31
|
+
orgPrefix = args[++i];
|
|
32
|
+
} else if (arg === '--help' || arg === '-h') {
|
|
33
|
+
console.log(`
|
|
34
|
+
Usage: npx create-ekka-desktop-app [project-dir] [options]
|
|
35
|
+
|
|
36
|
+
Options:
|
|
37
|
+
-c, --config <file> Use config file (skips all prompts)
|
|
38
|
+
-n, --name <name> App display name
|
|
39
|
+
-s, --slug <slug> App identifier (lowercase, hyphens only)
|
|
40
|
+
-e, --engine-url <url> EKKA Engine URL (required)
|
|
41
|
+
-o, --org <prefix> Organization prefix (default: ai.ekka)
|
|
42
|
+
-h, --help Show this help
|
|
43
|
+
|
|
44
|
+
Examples:
|
|
45
|
+
npx create-ekka-desktop-app my-app
|
|
46
|
+
npx create-ekka-desktop-app my-app --engine-url https://api.ekka.ai
|
|
47
|
+
npx create-ekka-desktop-app my-app --config ./app.config.json
|
|
48
|
+
`);
|
|
49
|
+
process.exit(0);
|
|
50
|
+
} else if (!arg.startsWith('-') && !projectName) {
|
|
51
|
+
projectName = arg;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Helper to prompt for input
|
|
56
|
+
async function prompt(question, defaultValue = '') {
|
|
57
|
+
const rl = createInterface({
|
|
58
|
+
input: process.stdin,
|
|
59
|
+
output: process.stdout
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
return new Promise((resolve) => {
|
|
63
|
+
const displayDefault = defaultValue ? ` (${defaultValue})` : '';
|
|
64
|
+
rl.question(`${question}${displayDefault}: `, (answer) => {
|
|
65
|
+
rl.close();
|
|
66
|
+
resolve(answer.trim() || defaultValue);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
}
|
|
11
70
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
71
|
+
// Helper to convert to title case
|
|
72
|
+
function titleCase(str) {
|
|
73
|
+
return str
|
|
74
|
+
.split('-')
|
|
75
|
+
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
|
76
|
+
.join(' ');
|
|
15
77
|
}
|
|
16
78
|
|
|
17
|
-
|
|
79
|
+
// Helper to slugify
|
|
80
|
+
function slugify(str) {
|
|
81
|
+
return str
|
|
82
|
+
.toLowerCase()
|
|
83
|
+
.replace(/[^a-z0-9-]/g, '-')
|
|
84
|
+
.replace(/-+/g, '-')
|
|
85
|
+
.replace(/^-|-$/g, '');
|
|
86
|
+
}
|
|
18
87
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
88
|
+
// Validate slug
|
|
89
|
+
function isValidSlug(slug) {
|
|
90
|
+
return /^[a-z][a-z0-9-]*$/.test(slug);
|
|
22
91
|
}
|
|
23
92
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
93
|
+
// Main
|
|
94
|
+
async function main() {
|
|
95
|
+
console.log(`
|
|
96
|
+
╔══════════════════════════════════════════════════════════════╗
|
|
97
|
+
║ CREATE EKKA DESKTOP APP ║
|
|
98
|
+
╚══════════════════════════════════════════════════════════════╝
|
|
99
|
+
`);
|
|
100
|
+
|
|
101
|
+
// If config file provided, load it and skip prompts
|
|
102
|
+
let config = null;
|
|
103
|
+
if (configFile) {
|
|
104
|
+
if (!existsSync(configFile)) {
|
|
105
|
+
console.error(`Error: Config file not found: ${configFile}`);
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
config = JSON.parse(readFileSync(configFile, 'utf8'));
|
|
110
|
+
console.log(`Using config from: ${configFile}\n`);
|
|
111
|
+
} catch (e) {
|
|
112
|
+
console.error(`Error: Invalid JSON in config file: ${e.message}`);
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Get project directory
|
|
118
|
+
if (!projectName) {
|
|
119
|
+
projectName = await prompt('Project directory', 'my-ekka-app');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const targetDir = resolve(process.cwd(), projectName);
|
|
123
|
+
|
|
124
|
+
if (existsSync(targetDir)) {
|
|
125
|
+
console.error(`\nError: Directory "${projectName}" already exists.`);
|
|
126
|
+
process.exit(1);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Build config from flags, prompts, or config file
|
|
130
|
+
if (!config) {
|
|
131
|
+
// Get app name
|
|
132
|
+
if (!appName) {
|
|
133
|
+
appName = await prompt('App display name', titleCase(projectName));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Get app slug
|
|
137
|
+
if (!appSlug) {
|
|
138
|
+
const defaultSlug = slugify(projectName);
|
|
139
|
+
appSlug = await prompt('App identifier (lowercase, hyphens)', defaultSlug);
|
|
140
|
+
while (!isValidSlug(appSlug)) {
|
|
141
|
+
console.log(' ⚠ Must be lowercase letters, numbers, and hyphens only');
|
|
142
|
+
appSlug = await prompt('App identifier (lowercase, hyphens)', defaultSlug);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Get engine URL
|
|
147
|
+
if (!engineUrl) {
|
|
148
|
+
engineUrl = await prompt('EKKA Engine URL', 'https://api.ekka.ai');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Get org prefix (only prompt if not all values provided via flags)
|
|
152
|
+
if (orgPrefix === 'ai.ekka' && !process.argv.includes('--engine-url') && !process.argv.includes('-e')) {
|
|
153
|
+
const customOrg = await prompt('Organization prefix', 'ai.ekka');
|
|
154
|
+
if (customOrg) orgPrefix = customOrg;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
config = {
|
|
158
|
+
app: {
|
|
159
|
+
name: appName,
|
|
160
|
+
slug: appSlug,
|
|
161
|
+
identifier: `${orgPrefix}.${appSlug.replace(/-/g, '')}`
|
|
162
|
+
},
|
|
163
|
+
storage: {
|
|
164
|
+
homeFolderName: `.${appSlug}`,
|
|
165
|
+
keychainService: `${orgPrefix}.${appSlug.replace(/-/g, '')}`
|
|
166
|
+
},
|
|
167
|
+
engine: {
|
|
168
|
+
url: engineUrl
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Validate config
|
|
174
|
+
if (!config.app?.slug) {
|
|
175
|
+
console.error('\nError: app.slug is required in config');
|
|
176
|
+
process.exit(1);
|
|
177
|
+
}
|
|
178
|
+
if (!config.engine?.url) {
|
|
179
|
+
console.error('\nError: engine.url is required in config');
|
|
180
|
+
process.exit(1);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Derive missing values
|
|
184
|
+
config.app.name = config.app.name || titleCase(config.app.slug);
|
|
185
|
+
config.app.identifier = config.app.identifier || `ai.ekka.${config.app.slug.replace(/-/g, '')}`;
|
|
186
|
+
config.storage = config.storage || {};
|
|
187
|
+
config.storage.homeFolderName = config.storage.homeFolderName || `.${config.app.slug}`;
|
|
188
|
+
config.storage.keychainService = config.storage.keychainService || config.app.identifier;
|
|
189
|
+
|
|
190
|
+
console.log(`
|
|
191
|
+
Creating EKKA desktop app in ${targetDir}...
|
|
192
|
+
|
|
193
|
+
App Name: ${config.app.name}
|
|
194
|
+
App Slug: ${config.app.slug}
|
|
195
|
+
Identifier: ${config.app.identifier}
|
|
196
|
+
Home Folder: ~/${config.storage.homeFolderName}
|
|
197
|
+
Engine URL: ${config.engine.url}
|
|
198
|
+
`);
|
|
199
|
+
|
|
200
|
+
// Copy template
|
|
201
|
+
mkdirSync(targetDir, { recursive: true });
|
|
202
|
+
cpSync(templateDir, targetDir, { recursive: true });
|
|
203
|
+
|
|
204
|
+
// Write app.config.json
|
|
205
|
+
const appConfigPath = join(targetDir, 'app.config.json');
|
|
206
|
+
writeFileSync(appConfigPath, JSON.stringify(config, null, 2) + '\n');
|
|
207
|
+
|
|
208
|
+
// Update package.json with project name
|
|
209
|
+
const pkgPath = join(targetDir, 'package.json');
|
|
210
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
211
|
+
pkg.name = config.app.slug;
|
|
212
|
+
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
|
213
|
+
|
|
214
|
+
// Update branding/app.json
|
|
215
|
+
const brandingPath = join(targetDir, 'branding', 'app.json');
|
|
216
|
+
const branding = JSON.parse(readFileSync(brandingPath, 'utf8'));
|
|
217
|
+
branding.name = config.app.name;
|
|
218
|
+
branding.bundleId = config.app.identifier;
|
|
219
|
+
writeFileSync(brandingPath, JSON.stringify(branding, null, 2) + '\n');
|
|
220
|
+
|
|
221
|
+
// Update src-tauri/Cargo.toml crate name
|
|
222
|
+
const cargoPath = join(targetDir, 'src-tauri', 'Cargo.toml');
|
|
223
|
+
let cargoContent = readFileSync(cargoPath, 'utf8');
|
|
224
|
+
cargoContent = cargoContent.replace(/^name = ".*"$/m, `name = "${config.app.slug}"`);
|
|
225
|
+
cargoContent = cargoContent.replace(/^description = ".*"$/m, `description = "${config.app.name}"`);
|
|
226
|
+
writeFileSync(cargoPath, cargoContent);
|
|
227
|
+
|
|
228
|
+
// Update src-tauri/tauri.conf.json identifier
|
|
229
|
+
const tauriConfPath = join(targetDir, 'src-tauri', 'tauri.conf.json');
|
|
230
|
+
const tauriConf = JSON.parse(readFileSync(tauriConfPath, 'utf8'));
|
|
231
|
+
tauriConf.identifier = config.app.identifier;
|
|
232
|
+
writeFileSync(tauriConfPath, JSON.stringify(tauriConf, null, 2) + '\n');
|
|
233
|
+
|
|
234
|
+
console.log(`
|
|
235
|
+
╔══════════════════════════════════════════════════════════════╗
|
|
236
|
+
║ SUCCESS! Your app is ready. ║
|
|
237
|
+
╚══════════════════════════════════════════════════════════════╝
|
|
238
|
+
|
|
239
|
+
To get started:
|
|
59
240
|
|
|
60
241
|
cd ${projectName}
|
|
61
242
|
npm install
|
|
243
|
+
npm run tauri:dev
|
|
62
244
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
npm run tauri:dev # Desktop (native window)
|
|
245
|
+
Configuration:
|
|
246
|
+
Edit app.config.json to change app identity or engine URL.
|
|
66
247
|
|
|
67
248
|
Build:
|
|
68
|
-
npm run tauri:build
|
|
69
|
-
|
|
70
|
-
Edit src/app/App.tsx to build your UI.
|
|
71
|
-
Delete src/demo/ when ready.
|
|
249
|
+
npm run tauri:build
|
|
72
250
|
`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
main().catch(console.error);
|
package/package.json
CHANGED
|
@@ -1,3 +1,81 @@
|
|
|
1
|
+
use std::fs;
|
|
2
|
+
|
|
1
3
|
fn main() {
|
|
4
|
+
// Rerun if config changes
|
|
5
|
+
println!("cargo:rerun-if-changed=../app.config.json");
|
|
6
|
+
|
|
7
|
+
// 1. Read app.config.json
|
|
8
|
+
let config_path = "../app.config.json";
|
|
9
|
+
let config_str = fs::read_to_string(config_path).unwrap_or_else(|_| {
|
|
10
|
+
panic!(
|
|
11
|
+
"\n\n\
|
|
12
|
+
╔══════════════════════════════════════════════════════════════════╗\n\
|
|
13
|
+
║ BUILD ERROR: app.config.json not found ║\n\
|
|
14
|
+
╠══════════════════════════════════════════════════════════════════╣\n\
|
|
15
|
+
║ This file is required and should have been created by CDA. ║\n\
|
|
16
|
+
║ Regenerate your app with: ║\n\
|
|
17
|
+
║ ║\n\
|
|
18
|
+
║ npx create-ekka-desktop-app@latest my-app ║\n\
|
|
19
|
+
║ ║\n\
|
|
20
|
+
╚══════════════════════════════════════════════════════════════════╝\n\n"
|
|
21
|
+
)
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
let config: serde_json::Value = serde_json::from_str(&config_str).unwrap_or_else(|e| {
|
|
25
|
+
panic!("\n\nBUILD ERROR: Invalid app.config.json: {}\n\n", e)
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// 2. Extract and validate required fields
|
|
29
|
+
let app = config.get("app").expect("app.config.json missing 'app' section");
|
|
30
|
+
let storage = config.get("storage").expect("app.config.json missing 'storage' section");
|
|
31
|
+
let engine = config.get("engine").expect("app.config.json missing 'engine' section");
|
|
32
|
+
|
|
33
|
+
let app_name = app.get("name").and_then(|v| v.as_str())
|
|
34
|
+
.expect("app.config.json: app.name is required");
|
|
35
|
+
let app_slug = app.get("slug").and_then(|v| v.as_str())
|
|
36
|
+
.expect("app.config.json: app.slug is required");
|
|
37
|
+
let app_identifier = app.get("identifier").and_then(|v| v.as_str())
|
|
38
|
+
.expect("app.config.json: app.identifier is required");
|
|
39
|
+
|
|
40
|
+
let home_folder = storage.get("homeFolderName").and_then(|v| v.as_str())
|
|
41
|
+
.expect("app.config.json: storage.homeFolderName is required");
|
|
42
|
+
let keychain_service = storage.get("keychainService").and_then(|v| v.as_str())
|
|
43
|
+
.expect("app.config.json: storage.keychainService is required");
|
|
44
|
+
|
|
45
|
+
let engine_url = engine.get("url").and_then(|v| v.as_str())
|
|
46
|
+
.expect("app.config.json: engine.url is required");
|
|
47
|
+
|
|
48
|
+
// 3. Validate non-empty
|
|
49
|
+
if app_slug.is_empty() {
|
|
50
|
+
panic!("\n\nBUILD ERROR: app.slug cannot be empty in app.config.json\n\n");
|
|
51
|
+
}
|
|
52
|
+
if home_folder.is_empty() {
|
|
53
|
+
panic!("\n\nBUILD ERROR: storage.homeFolderName cannot be empty in app.config.json\n\n");
|
|
54
|
+
}
|
|
55
|
+
if engine_url.is_empty() {
|
|
56
|
+
panic!(
|
|
57
|
+
"\n\n\
|
|
58
|
+
╔══════════════════════════════════════════════════════════════════╗\n\
|
|
59
|
+
║ BUILD ERROR: engine.url is empty in app.config.json ║\n\
|
|
60
|
+
╠══════════════════════════════════════════════════════════════════╣\n\
|
|
61
|
+
║ Set your EKKA Engine URL in app.config.json: ║\n\
|
|
62
|
+
║ ║\n\
|
|
63
|
+
║ \"engine\": {{ ║\n\
|
|
64
|
+
║ \"url\": \"https://api.ekka.ai\" ║\n\
|
|
65
|
+
║ }} ║\n\
|
|
66
|
+
║ ║\n\
|
|
67
|
+
╚══════════════════════════════════════════════════════════════════╝\n\n"
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 4. Bake values into binary at compile time
|
|
72
|
+
println!("cargo:rustc-env=EKKA_APP_NAME={}", app_name);
|
|
73
|
+
println!("cargo:rustc-env=EKKA_APP_SLUG={}", app_slug);
|
|
74
|
+
println!("cargo:rustc-env=EKKA_APP_IDENTIFIER={}", app_identifier);
|
|
75
|
+
println!("cargo:rustc-env=EKKA_HOME_FOLDER={}", home_folder);
|
|
76
|
+
println!("cargo:rustc-env=EKKA_KEYCHAIN_SERVICE={}", keychain_service);
|
|
77
|
+
println!("cargo:rustc-env=EKKA_ENGINE_URL={}", engine_url);
|
|
78
|
+
|
|
79
|
+
// Continue with tauri build
|
|
2
80
|
tauri_build::build()
|
|
3
81
|
}
|
|
@@ -2,19 +2,20 @@
|
|
|
2
2
|
//!
|
|
3
3
|
//! Handles initialization and resolution of the EKKA home directory.
|
|
4
4
|
|
|
5
|
+
use crate::config;
|
|
5
6
|
use ekka_sdk_core::ekka_home_bootstrap::{BootstrapConfig, EpochSource, HomeBootstrap, HomeStrategy};
|
|
6
7
|
use std::path::PathBuf;
|
|
7
8
|
|
|
8
|
-
/// Standard bootstrap configuration
|
|
9
|
+
/// Standard bootstrap configuration - all values from app.config.json (baked at build time)
|
|
9
10
|
pub fn bootstrap_config() -> BootstrapConfig {
|
|
10
11
|
BootstrapConfig {
|
|
11
|
-
app_name:
|
|
12
|
-
default_folder_name:
|
|
12
|
+
app_name: config::app_name().to_string(),
|
|
13
|
+
default_folder_name: config::home_folder().to_string(),
|
|
13
14
|
home_strategy: HomeStrategy::DataHome {
|
|
14
15
|
env_var: "EKKA_DATA_HOME".to_string(),
|
|
15
16
|
},
|
|
16
17
|
marker_filename: ".ekka-marker.json".to_string(),
|
|
17
|
-
keychain_service:
|
|
18
|
+
keychain_service: config::keychain_service().to_string(),
|
|
18
19
|
subdirs: vec!["vault".to_string(), "db".to_string(), "tmp".to_string()],
|
|
19
20
|
epoch_source: EpochSource::EnvVar("EKKA_SECURITY_EPOCH".to_string()),
|
|
20
21
|
storage_layout_version: "v1".to_string(),
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
//! Entry points for TypeScript → Rust communication.
|
|
4
4
|
|
|
5
5
|
use crate::bootstrap::initialize_home;
|
|
6
|
+
use crate::config;
|
|
6
7
|
use crate::engine_process;
|
|
7
8
|
use crate::grants::require_home_granted;
|
|
8
9
|
use crate::handlers;
|
|
@@ -455,9 +456,10 @@ fn handle_node_credentials_clear() -> EngineResponse {
|
|
|
455
456
|
fn handle_runner_task_stats(state: &EngineState) -> EngineResponse {
|
|
456
457
|
use crate::state::NodeAuthState;
|
|
457
458
|
|
|
458
|
-
// Get engine URL (
|
|
459
|
-
let engine_url =
|
|
460
|
-
.
|
|
459
|
+
// Get engine URL (baked at build time)
|
|
460
|
+
let engine_url = option_env!("EKKA_ENGINE_URL")
|
|
461
|
+
.unwrap_or("https://api.ekka.ai")
|
|
462
|
+
.to_string();
|
|
461
463
|
|
|
462
464
|
// Get node auth token for Authorization header
|
|
463
465
|
let node_token = match state.get_node_auth_token() {
|
|
@@ -540,7 +542,7 @@ fn handle_runner_task_stats(state: &EngineState) -> EngineResponse {
|
|
|
540
542
|
.header("X-EKKA-CORRELATION-ID", &request_id)
|
|
541
543
|
.header("X-EKKA-MODULE", "engine.runner_tasks")
|
|
542
544
|
.header("X-EKKA-ACTION", "stats")
|
|
543
|
-
.header("X-EKKA-CLIENT",
|
|
545
|
+
.header("X-EKKA-CLIENT", config::app_slug())
|
|
544
546
|
.header("X-EKKA-CLIENT-VERSION", "0.2.0")
|
|
545
547
|
.send();
|
|
546
548
|
|
|
@@ -819,8 +821,9 @@ fn handle_bootstrap_node_session(payload: &Value, state: &EngineState) -> Engine
|
|
|
819
821
|
if start_runner {
|
|
820
822
|
// Build runner config from node auth token
|
|
821
823
|
let runner_config = node_auth::NodeSessionRunnerConfig {
|
|
822
|
-
engine_url:
|
|
823
|
-
.
|
|
824
|
+
engine_url: option_env!("EKKA_ENGINE_URL")
|
|
825
|
+
.map(|s| s.to_string())
|
|
826
|
+
.or_else(|| std::env::var("ENGINE_URL").ok())
|
|
824
827
|
.unwrap_or_default(),
|
|
825
828
|
node_url: std::env::var("NODE_URL").unwrap_or_else(|_| "http://127.0.0.1:7777".to_string()),
|
|
826
829
|
session_token: node_token.token.clone(),
|
|
@@ -900,7 +903,7 @@ fn build_security_headers(jwt: Option<&str>, module: &str, action: &str) -> Vec<
|
|
|
900
903
|
("X-EKKA-PROOF-TYPE".to_string(), if jwt.is_some() { "jwt" } else { "none" }.to_string()),
|
|
901
904
|
("X-EKKA-MODULE".to_string(), module.to_string()),
|
|
902
905
|
("X-EKKA-ACTION".to_string(), action.to_string()),
|
|
903
|
-
("X-EKKA-CLIENT".to_string(),
|
|
906
|
+
("X-EKKA-CLIENT".to_string(), config::app_slug().to_string()),
|
|
904
907
|
("X-EKKA-CLIENT-VERSION".to_string(), "0.2.0".to_string()),
|
|
905
908
|
];
|
|
906
909
|
|
|
@@ -913,8 +916,9 @@ fn build_security_headers(jwt: Option<&str>, module: &str, action: &str) -> Vec<
|
|
|
913
916
|
|
|
914
917
|
/// Create a workflow run (POST /engine/workflow-runs)
|
|
915
918
|
fn handle_workflow_runs_create(payload: &Value) -> EngineResponse {
|
|
916
|
-
let engine_url =
|
|
917
|
-
.
|
|
919
|
+
let engine_url = option_env!("EKKA_ENGINE_URL")
|
|
920
|
+
.unwrap_or("http://localhost:3200")
|
|
921
|
+
.to_string();
|
|
918
922
|
|
|
919
923
|
// Extract request body
|
|
920
924
|
let request = match payload.get("request") {
|
|
@@ -974,8 +978,9 @@ fn handle_workflow_runs_create(payload: &Value) -> EngineResponse {
|
|
|
974
978
|
|
|
975
979
|
/// Get a workflow run (GET /engine/workflow-runs/{id})
|
|
976
980
|
fn handle_workflow_runs_get(payload: &Value) -> EngineResponse {
|
|
977
|
-
let engine_url =
|
|
978
|
-
.
|
|
981
|
+
let engine_url = option_env!("EKKA_ENGINE_URL")
|
|
982
|
+
.unwrap_or("http://localhost:3200")
|
|
983
|
+
.to_string();
|
|
979
984
|
|
|
980
985
|
// Extract workflow run ID
|
|
981
986
|
let id = match payload.get("id").and_then(|v| v.as_str()) {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
//! Compile-time app configuration
|
|
2
|
+
//!
|
|
3
|
+
//! All values are baked at build time from app.config.json.
|
|
4
|
+
//! ZERO runtime configuration or hardcoding.
|
|
5
|
+
|
|
6
|
+
#![allow(dead_code)] // Not all config values are used yet
|
|
7
|
+
|
|
8
|
+
macro_rules! baked_config {
|
|
9
|
+
($name:ident, $env:literal) => {
|
|
10
|
+
pub fn $name() -> &'static str {
|
|
11
|
+
option_env!($env).expect(concat!(
|
|
12
|
+
$env,
|
|
13
|
+
" not baked at build time. Check build.rs and app.config.json"
|
|
14
|
+
))
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// App display name (e.g., "EKKA Studio")
|
|
20
|
+
baked_config!(app_name, "EKKA_APP_NAME");
|
|
21
|
+
|
|
22
|
+
// App slug for machine use (e.g., "ekka-studio")
|
|
23
|
+
baked_config!(app_slug, "EKKA_APP_SLUG");
|
|
24
|
+
|
|
25
|
+
// App identifier for OS (e.g., "ai.ekka.studio")
|
|
26
|
+
baked_config!(app_identifier, "EKKA_APP_IDENTIFIER");
|
|
27
|
+
|
|
28
|
+
// Home folder name (e.g., ".ekka-studio")
|
|
29
|
+
baked_config!(home_folder, "EKKA_HOME_FOLDER");
|
|
30
|
+
|
|
31
|
+
// Keychain service identifier (e.g., "ai.ekka.studio")
|
|
32
|
+
baked_config!(keychain_service, "EKKA_KEYCHAIN_SERVICE");
|
|
33
|
+
|
|
34
|
+
// EKKA Engine URL (e.g., "https://api.ekka.ai")
|
|
35
|
+
baked_config!(engine_url, "EKKA_ENGINE_URL");
|
|
@@ -46,9 +46,9 @@ fn build_engine_env() -> Result<Vec<(&'static str, String)>, &'static str> {
|
|
|
46
46
|
// EKKA_RUNNER_MODE=engine (always set)
|
|
47
47
|
env.push(("EKKA_RUNNER_MODE", "engine".to_string()));
|
|
48
48
|
|
|
49
|
-
// EKKA_ENGINE_URL -
|
|
50
|
-
if let
|
|
51
|
-
env.push(("EKKA_ENGINE_URL", url));
|
|
49
|
+
// EKKA_ENGINE_URL - baked at build time
|
|
50
|
+
if let Some(url) = option_env!("EKKA_ENGINE_URL") {
|
|
51
|
+
env.push(("EKKA_ENGINE_URL", url.to_string()));
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
// EKKA_INTERNAL_SERVICE_KEY - required for engine mode
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
mod bootstrap;
|
|
9
9
|
mod commands;
|
|
10
|
+
mod config;
|
|
10
11
|
mod device_secret;
|
|
11
12
|
mod engine_process;
|
|
12
13
|
mod grants;
|
|
@@ -74,6 +75,14 @@ fn main() {
|
|
|
74
75
|
"Required security env vars"
|
|
75
76
|
);
|
|
76
77
|
|
|
78
|
+
// Log build-time baked engine URL presence (not the URL itself)
|
|
79
|
+
let engine_url_baked = option_env!("EKKA_ENGINE_URL").is_some();
|
|
80
|
+
tracing::info!(
|
|
81
|
+
op = "desktop.engine_url.baked",
|
|
82
|
+
present = engine_url_baked,
|
|
83
|
+
"EKKA_ENGINE_URL baked at build time"
|
|
84
|
+
);
|
|
85
|
+
|
|
77
86
|
// Check for stored node credentials (vault-backed)
|
|
78
87
|
let has_creds = node_credentials::has_credentials();
|
|
79
88
|
|
|
@@ -112,13 +121,14 @@ fn main() {
|
|
|
112
121
|
}
|
|
113
122
|
|
|
114
123
|
// Authenticate node with server before spawning engine
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
124
|
+
// EKKA_ENGINE_URL is baked at build time via build.rs
|
|
125
|
+
let engine_url = match option_env!("EKKA_ENGINE_URL") {
|
|
126
|
+
Some(url) => url.to_string(),
|
|
127
|
+
None => {
|
|
118
128
|
tracing::warn!(
|
|
119
129
|
op = "desktop.engine.start.blocked",
|
|
120
130
|
reason = "missing_engine_url",
|
|
121
|
-
"Engine start blocked - EKKA_ENGINE_URL not
|
|
131
|
+
"Engine start blocked - EKKA_ENGINE_URL not baked at build time"
|
|
122
132
|
);
|
|
123
133
|
return;
|
|
124
134
|
}
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
#![allow(dead_code)] // API types may not all be used yet
|
|
19
19
|
|
|
20
|
+
use crate::config;
|
|
20
21
|
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
|
|
21
22
|
use chrono::{DateTime, Utc};
|
|
22
23
|
use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
|
|
@@ -240,7 +241,8 @@ pub fn get_private_key_vault_path(node_id: &Uuid) -> PathBuf {
|
|
|
240
241
|
fn derive_node_encryption_key(node_id: &Uuid, device_fingerprint: Option<&str>) -> KeyMaterial {
|
|
241
242
|
// Use node_id + device fingerprint for key derivation
|
|
242
243
|
// This binds the key to this specific node on this device
|
|
243
|
-
let
|
|
244
|
+
let default_device = format!("{}-default-device", config::app_slug());
|
|
245
|
+
let device_secret = device_fingerprint.unwrap_or(&default_device);
|
|
244
246
|
|
|
245
247
|
derive_key(
|
|
246
248
|
device_secret,
|
|
@@ -386,7 +388,7 @@ pub fn register_node(
|
|
|
386
388
|
"node_id": node_id.to_string(),
|
|
387
389
|
"public_key_b64": public_key_b64,
|
|
388
390
|
"default_workspace_id": default_workspace_id,
|
|
389
|
-
"display_name":
|
|
391
|
+
"display_name": config::app_name(),
|
|
390
392
|
"node_type": "desktop"
|
|
391
393
|
});
|
|
392
394
|
|
|
@@ -403,7 +405,7 @@ pub fn register_node(
|
|
|
403
405
|
.header("X-EKKA-CORRELATION-ID", &request_id)
|
|
404
406
|
.header("X-EKKA-MODULE", "desktop.node_auth")
|
|
405
407
|
.header("X-EKKA-ACTION", "register")
|
|
406
|
-
.header("X-EKKA-CLIENT",
|
|
408
|
+
.header("X-EKKA-CLIENT", config::app_slug())
|
|
407
409
|
.header("X-EKKA-CLIENT-VERSION", "0.2.0")
|
|
408
410
|
.json(&body)
|
|
409
411
|
.send()
|
|
@@ -459,7 +461,7 @@ pub fn get_challenge(engine_url: &str, node_id: &Uuid) -> Result<ChallengeRespon
|
|
|
459
461
|
.header("X-EKKA-CORRELATION-ID", &request_id)
|
|
460
462
|
.header("X-EKKA-MODULE", "desktop.node_auth")
|
|
461
463
|
.header("X-EKKA-ACTION", "challenge")
|
|
462
|
-
.header("X-EKKA-CLIENT",
|
|
464
|
+
.header("X-EKKA-CLIENT", config::app_slug())
|
|
463
465
|
.header("X-EKKA-CLIENT-VERSION", "0.2.0")
|
|
464
466
|
.json(&serde_json::json!({ "node_id": node_id.to_string() }))
|
|
465
467
|
.send()
|
|
@@ -503,7 +505,7 @@ pub fn create_session(
|
|
|
503
505
|
.header("X-EKKA-CORRELATION-ID", &request_id)
|
|
504
506
|
.header("X-EKKA-MODULE", "desktop.node_auth")
|
|
505
507
|
.header("X-EKKA-ACTION", "session")
|
|
506
|
-
.header("X-EKKA-CLIENT",
|
|
508
|
+
.header("X-EKKA-CLIENT", config::app_slug())
|
|
507
509
|
.header("X-EKKA-CLIENT-VERSION", "0.2.0")
|
|
508
510
|
.json(&serde_json::json!({
|
|
509
511
|
"node_id": node_id.to_string(),
|
|
@@ -770,9 +772,11 @@ pub struct NodeSessionRunnerConfig {
|
|
|
770
772
|
|
|
771
773
|
impl NodeSessionRunnerConfig {
|
|
772
774
|
pub fn from_session(session: &NodeSession, node_id: Uuid) -> Result<Self, String> {
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
.
|
|
775
|
+
// EKKA_ENGINE_URL baked at build time, ENGINE_URL as runtime fallback
|
|
776
|
+
let engine_url = option_env!("EKKA_ENGINE_URL")
|
|
777
|
+
.map(|s| s.to_string())
|
|
778
|
+
.or_else(|| std::env::var("ENGINE_URL").ok())
|
|
779
|
+
.ok_or("EKKA_ENGINE_URL not baked at build time and ENGINE_URL not set")?;
|
|
776
780
|
|
|
777
781
|
let node_url = std::env::var("NODE_URL").unwrap_or_else(|_| "http://127.0.0.1:7777".to_string());
|
|
778
782
|
|
|
@@ -796,7 +800,7 @@ impl NodeSessionRunnerConfig {
|
|
|
796
800
|
("X-REQUEST-ID", Uuid::new_v4().to_string()),
|
|
797
801
|
("X-EKKA-CORRELATION-ID", Uuid::new_v4().to_string()),
|
|
798
802
|
("X-EKKA-MODULE", "engine.runner_tasks".to_string()),
|
|
799
|
-
("X-EKKA-CLIENT",
|
|
803
|
+
("X-EKKA-CLIENT", config::app_slug().to_string()),
|
|
800
804
|
("X-EKKA-CLIENT-VERSION", "0.2.0".to_string()),
|
|
801
805
|
("X-EKKA-NODE-ID", self.node_id.to_string()),
|
|
802
806
|
]
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
//! - No OS keychain prompts
|
|
12
12
|
|
|
13
13
|
use crate::bootstrap::{initialize_home, resolve_home_path};
|
|
14
|
+
use crate::config;
|
|
14
15
|
use crate::node_vault_store::{
|
|
15
16
|
delete_node_secret, has_node_secret, read_node_secret, write_node_secret,
|
|
16
17
|
SECRET_ID_NODE_CREDENTIALS,
|
|
@@ -394,7 +395,7 @@ pub fn authenticate_node(engine_url: &str) -> Result<NodeAuthToken, CredentialsE
|
|
|
394
395
|
.header("X-EKKA-CORRELATION-ID", &request_id)
|
|
395
396
|
.header("X-EKKA-MODULE", "desktop.node_auth")
|
|
396
397
|
.header("X-EKKA-ACTION", "authenticate")
|
|
397
|
-
.header("X-EKKA-CLIENT",
|
|
398
|
+
.header("X-EKKA-CLIENT", config::app_slug())
|
|
398
399
|
.header("X-EKKA-CLIENT-VERSION", "0.2.0")
|
|
399
400
|
.json(&body)
|
|
400
401
|
.send()
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
#![allow(dead_code)] // API types and fields may not all be used yet
|
|
19
19
|
|
|
20
|
+
use crate::config;
|
|
20
21
|
use crate::node_auth::{
|
|
21
22
|
refresh_node_session, NodeSession, NodeSessionHolder, NodeSessionRunnerConfig,
|
|
22
23
|
};
|
|
@@ -236,7 +237,7 @@ impl NodeSessionRunner {
|
|
|
236
237
|
("X-REQUEST-ID", Uuid::new_v4().to_string()),
|
|
237
238
|
("X-EKKA-CORRELATION-ID", Uuid::new_v4().to_string()),
|
|
238
239
|
("X-EKKA-MODULE", "engine.runner_tasks".to_string()),
|
|
239
|
-
("X-EKKA-CLIENT",
|
|
240
|
+
("X-EKKA-CLIENT", config::app_slug().to_string()),
|
|
240
241
|
("X-EKKA-CLIENT-VERSION", "0.2.0".to_string()),
|
|
241
242
|
("X-EKKA-NODE-ID", self.node_id.to_string()),
|
|
242
243
|
])
|
|
@@ -700,7 +701,7 @@ impl NodeSessionRunnerHeartbeat {
|
|
|
700
701
|
.header("X-EKKA-CORRELATION-ID", Uuid::new_v4().to_string())
|
|
701
702
|
.header("X-EKKA-MODULE", "engine.runner_tasks")
|
|
702
703
|
.header("X-EKKA-ACTION", "heartbeat")
|
|
703
|
-
.header("X-EKKA-CLIENT",
|
|
704
|
+
.header("X-EKKA-CLIENT", config::app_slug())
|
|
704
705
|
.header("X-EKKA-CLIENT-VERSION", "0.2.0")
|
|
705
706
|
.header("X-EKKA-NODE-ID", self.node_id.to_string())
|
|
706
707
|
.json(&serde_json::json!({ "runner_id": self.runner_id }))
|
|
@@ -80,13 +80,13 @@ pub fn handle_grant(state: &EngineState) -> EngineResponse {
|
|
|
80
80
|
None => return EngineResponse::err("MARKER_INVALID", "Marker missing instance_id"),
|
|
81
81
|
};
|
|
82
82
|
|
|
83
|
-
// 4. Get engine URL
|
|
84
|
-
let engine_url = match
|
|
85
|
-
|
|
86
|
-
|
|
83
|
+
// 4. Get engine URL (baked at build time)
|
|
84
|
+
let engine_url = match option_env!("EKKA_ENGINE_URL") {
|
|
85
|
+
Some(u) => u,
|
|
86
|
+
None => {
|
|
87
87
|
return EngineResponse::err(
|
|
88
88
|
"ENGINE_NOT_CONFIGURED",
|
|
89
|
-
"EKKA_ENGINE_URL not
|
|
89
|
+
"EKKA_ENGINE_URL not baked at build time. Rebuild with EKKA_ENGINE_URL set.",
|
|
90
90
|
)
|
|
91
91
|
}
|
|
92
92
|
};
|
|
@@ -179,7 +179,7 @@ impl Default for RunnerStatus {
|
|
|
179
179
|
enabled: false,
|
|
180
180
|
state: RunnerLoopState::Stopped,
|
|
181
181
|
runner_id: None,
|
|
182
|
-
engine_url:
|
|
182
|
+
engine_url: option_env!("EKKA_ENGINE_URL").map(|s| s.to_string()),
|
|
183
183
|
last_poll_at: None,
|
|
184
184
|
last_claim_at: None,
|
|
185
185
|
last_complete_at: None,
|
|
@@ -259,9 +259,10 @@ impl RunnerState {
|
|
|
259
259
|
s.enabled = true;
|
|
260
260
|
s.state = RunnerLoopState::Running;
|
|
261
261
|
s.runner_id = Some(runner_id.to_string());
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
.
|
|
262
|
+
// EKKA_ENGINE_URL baked at build time, ENGINE_URL as runtime fallback
|
|
263
|
+
s.engine_url = option_env!("EKKA_ENGINE_URL")
|
|
264
|
+
.map(|s| s.to_string())
|
|
265
|
+
.or_else(|| std::env::var("ENGINE_URL").ok());
|
|
265
266
|
s.last_error = None;
|
|
266
267
|
});
|
|
267
268
|
}
|
|
@@ -511,11 +512,11 @@ impl GrantIssuer for EngineHttpGrantIssuer {
|
|
|
511
512
|
EkkaError::new(ops::codes::NOT_AUTHENTICATED, "Must login before requesting grant")
|
|
512
513
|
})?;
|
|
513
514
|
|
|
514
|
-
// Get engine URL
|
|
515
|
-
let engine_url =
|
|
515
|
+
// Get engine URL (baked at build time)
|
|
516
|
+
let engine_url = option_env!("EKKA_ENGINE_URL").ok_or_else(|| {
|
|
516
517
|
EkkaError::new(
|
|
517
518
|
ops::codes::ENGINE_ERROR,
|
|
518
|
-
"EKKA_ENGINE_URL not
|
|
519
|
+
"EKKA_ENGINE_URL not baked at build time. Rebuild with EKKA_ENGINE_URL set.",
|
|
519
520
|
)
|
|
520
521
|
})?;
|
|
521
522
|
|
|
@@ -613,10 +614,10 @@ impl GrantIssuer for EngineHttpGrantIssuer {
|
|
|
613
614
|
EkkaError::new(ops::codes::NOT_AUTHENTICATED, "Must login to revoke grant")
|
|
614
615
|
})?;
|
|
615
616
|
|
|
616
|
-
// Get engine URL (optional - revoke is best effort)
|
|
617
|
-
let engine_url = match
|
|
618
|
-
|
|
619
|
-
|
|
617
|
+
// Get engine URL (baked at build time, optional - revoke is best effort)
|
|
618
|
+
let engine_url = match option_env!("EKKA_ENGINE_URL") {
|
|
619
|
+
Some(url) => url,
|
|
620
|
+
None => return Ok(()), // No engine baked, just return success
|
|
620
621
|
};
|
|
621
622
|
|
|
622
623
|
// Make revoke request (best effort)
|