kandev 0.54.0 → 0.55.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/dist/constants.js +16 -0
- package/dist/run.js +37 -27
- package/dist/service/args.js +26 -0
- package/dist/service/index.js +7 -0
- package/dist/service/launchctl.js +73 -0
- package/dist/service/linux.js +20 -2
- package/dist/service/macos.js +33 -8
- package/dist/service/metadata.js +46 -0
- package/dist/service/paths.js +11 -6
- package/dist/service/self_update.js +223 -0
- package/dist/service/templates.js +20 -0
- package/dist/shared.js +1 -0
- package/dist/start.js +5 -1
- package/package.json +6 -6
package/dist/constants.js
CHANGED
|
@@ -4,6 +4,10 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.DEV_KANDEV_DOTDIR = exports.DATA_DIR = exports.CACHE_DIR = exports.KANDEV_TASKS_DIR = exports.KANDEV_HOME_DIR = exports.KANDEV_DOTDIR = exports.HEALTH_TIMEOUT_MS_DEV = exports.HEALTH_TIMEOUT_MS_RELEASE = exports.RANDOM_PORT_RETRIES = exports.RANDOM_PORT_MAX = exports.RANDOM_PORT_MIN = exports.DEFAULT_AGENTCTL_PORT = exports.DEFAULT_WEB_PORT = exports.DEFAULT_BACKEND_PORT = void 0;
|
|
7
|
+
exports.resolveKandevHomeDir = resolveKandevHomeDir;
|
|
8
|
+
exports.resolveDataDir = resolveDataDir;
|
|
9
|
+
exports.resolveCacheDir = resolveCacheDir;
|
|
10
|
+
exports.resolveDatabasePath = resolveDatabasePath;
|
|
7
11
|
exports.devKandevHome = devKandevHome;
|
|
8
12
|
const node_os_1 = __importDefault(require("node:os"));
|
|
9
13
|
const node_path_1 = __importDefault(require("node:path"));
|
|
@@ -29,6 +33,18 @@ exports.KANDEV_TASKS_DIR = node_path_1.default.join(exports.KANDEV_HOME_DIR, "ta
|
|
|
29
33
|
// Local user cache/data directories for release bundles and DB.
|
|
30
34
|
exports.CACHE_DIR = node_path_1.default.join(exports.KANDEV_HOME_DIR, "bin");
|
|
31
35
|
exports.DATA_DIR = node_path_1.default.join(exports.KANDEV_HOME_DIR, "data");
|
|
36
|
+
function resolveKandevHomeDir(env = process.env) {
|
|
37
|
+
return env.KANDEV_HOME_DIR?.trim() || exports.KANDEV_HOME_DIR;
|
|
38
|
+
}
|
|
39
|
+
function resolveDataDir(env = process.env) {
|
|
40
|
+
return node_path_1.default.join(resolveKandevHomeDir(env), "data");
|
|
41
|
+
}
|
|
42
|
+
function resolveCacheDir(env = process.env) {
|
|
43
|
+
return node_path_1.default.join(resolveKandevHomeDir(env), "bin");
|
|
44
|
+
}
|
|
45
|
+
function resolveDatabasePath(env = process.env) {
|
|
46
|
+
return env.KANDEV_DATABASE_PATH?.trim() || node_path_1.default.join(resolveDataDir(env), "kandev.db");
|
|
47
|
+
}
|
|
32
48
|
// Dev-mode root: an isolated kandev home inside the repo so that running
|
|
33
49
|
// `make dev` from inside a kandev-spawned task workspace does not touch the
|
|
34
50
|
// user's production state.
|
package/dist/run.js
CHANGED
|
@@ -27,8 +27,9 @@ const web_1 = require("./web");
|
|
|
27
27
|
* the highest semver tag available in the cache.
|
|
28
28
|
*/
|
|
29
29
|
function findCachedRelease(platformDir, version) {
|
|
30
|
+
const rootCacheDir = (0, constants_1.resolveCacheDir)();
|
|
30
31
|
if (version) {
|
|
31
|
-
const cacheDir = node_path_1.default.join(
|
|
32
|
+
const cacheDir = node_path_1.default.join(rootCacheDir, version, platformDir);
|
|
32
33
|
const bundleDir = node_path_1.default.join(cacheDir, "kandev");
|
|
33
34
|
const backendBin = node_path_1.default.join(bundleDir, "bin", (0, platform_1.getBinaryName)("kandev"));
|
|
34
35
|
if (node_fs_1.default.existsSync(backendBin)) {
|
|
@@ -37,14 +38,14 @@ function findCachedRelease(platformDir, version) {
|
|
|
37
38
|
return null;
|
|
38
39
|
}
|
|
39
40
|
// No version specified — scan for cached tags and pick the latest.
|
|
40
|
-
if (!node_fs_1.default.existsSync(
|
|
41
|
+
if (!node_fs_1.default.existsSync(rootCacheDir))
|
|
41
42
|
return null;
|
|
42
|
-
const entries = node_fs_1.default.readdirSync(
|
|
43
|
+
const entries = node_fs_1.default.readdirSync(rootCacheDir).filter((d) => d.startsWith("v"));
|
|
43
44
|
if (entries.length === 0)
|
|
44
45
|
return null;
|
|
45
46
|
const sorted = (0, version_1.sortVersionsDesc)(entries);
|
|
46
47
|
for (const tag of sorted) {
|
|
47
|
-
const cacheDir = node_path_1.default.join(
|
|
48
|
+
const cacheDir = node_path_1.default.join(rootCacheDir, tag, platformDir);
|
|
48
49
|
const bundleDir = node_path_1.default.join(cacheDir, "kandev");
|
|
49
50
|
const backendBin = node_path_1.default.join(bundleDir, "bin", (0, platform_1.getBinaryName)("kandev"));
|
|
50
51
|
if (node_fs_1.default.existsSync(backendBin)) {
|
|
@@ -59,10 +60,11 @@ function findCachedRelease(platformDir, version) {
|
|
|
59
60
|
* The previous version is kept as a fallback for offline use.
|
|
60
61
|
*/
|
|
61
62
|
function cleanOldReleases(currentTag) {
|
|
63
|
+
const rootCacheDir = (0, constants_1.resolveCacheDir)();
|
|
62
64
|
try {
|
|
63
|
-
if (!node_fs_1.default.existsSync(
|
|
65
|
+
if (!node_fs_1.default.existsSync(rootCacheDir))
|
|
64
66
|
return;
|
|
65
|
-
const entries = node_fs_1.default.readdirSync(
|
|
67
|
+
const entries = node_fs_1.default.readdirSync(rootCacheDir).filter((d) => d.startsWith("v"));
|
|
66
68
|
if (entries.length <= 2)
|
|
67
69
|
return;
|
|
68
70
|
const sorted = (0, version_1.sortVersionsDesc)(entries);
|
|
@@ -70,7 +72,7 @@ function cleanOldReleases(currentTag) {
|
|
|
70
72
|
const keep = new Set([currentTag, sorted[0], sorted[1]]);
|
|
71
73
|
for (const entry of entries) {
|
|
72
74
|
if (!keep.has(entry)) {
|
|
73
|
-
node_fs_1.default.rmSync(node_path_1.default.join(
|
|
75
|
+
node_fs_1.default.rmSync(node_path_1.default.join(rootCacheDir, entry), { recursive: true, force: true });
|
|
74
76
|
}
|
|
75
77
|
}
|
|
76
78
|
}
|
|
@@ -87,7 +89,7 @@ async function downloadRuntimeVersion(runtimeVersion) {
|
|
|
87
89
|
const release = await (0, github_1.getRelease)(runtimeVersion);
|
|
88
90
|
const tag = release.tag_name;
|
|
89
91
|
const assetName = `kandev-${platformDir}.tar.gz`;
|
|
90
|
-
const cacheDir = node_path_1.default.join(constants_1.
|
|
92
|
+
const cacheDir = node_path_1.default.join((0, constants_1.resolveCacheDir)(), tag, platformDir);
|
|
91
93
|
const archivePath = await (0, github_1.ensureAsset)(tag, assetName, cacheDir, (downloaded, total) => {
|
|
92
94
|
const percent = total ? Math.round((downloaded / total) * 100) : 0;
|
|
93
95
|
const mb = (downloaded / (1024 * 1024)).toFixed(1);
|
|
@@ -114,7 +116,7 @@ async function prepareBundleForLaunch({ runtimeVersion, backendPort, webPort, ve
|
|
|
114
116
|
else {
|
|
115
117
|
try {
|
|
116
118
|
tag = await downloadRuntimeVersion(runtimeVersion);
|
|
117
|
-
const cacheDir = node_path_1.default.join(constants_1.
|
|
119
|
+
const cacheDir = node_path_1.default.join((0, constants_1.resolveCacheDir)(), tag, platformDir);
|
|
118
120
|
bundleDir = (0, bundle_1.findBundleRoot)(cacheDir);
|
|
119
121
|
}
|
|
120
122
|
catch (err) {
|
|
@@ -141,25 +143,33 @@ async function prepareBundleForLaunch({ runtimeVersion, backendPort, webPort, ve
|
|
|
141
143
|
const backendUrl = `http://localhost:${actualBackendPort}`;
|
|
142
144
|
const showOutput = verbose || debug;
|
|
143
145
|
const logLevel = process.env.KANDEV_LOG_LEVEL?.trim() || (debug ? "debug" : verbose ? "info" : "warn");
|
|
144
|
-
|
|
145
|
-
|
|
146
|
+
const dataDir = (0, constants_1.resolveDataDir)();
|
|
147
|
+
node_fs_1.default.mkdirSync(dataDir, { recursive: true });
|
|
148
|
+
const dbPath = (0, constants_1.resolveDatabasePath)();
|
|
146
149
|
const backendBin = node_path_1.default.join(bundleDir, "bin", (0, platform_1.getBinaryName)("kandev"));
|
|
147
|
-
const backendEnv = {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
150
|
+
const backendEnv = (0, shared_1.buildBackendEnv)({
|
|
151
|
+
ports: {
|
|
152
|
+
backendPort: actualBackendPort,
|
|
153
|
+
webPort: actualWebPort,
|
|
154
|
+
agentctlPort,
|
|
155
|
+
backendUrl,
|
|
156
|
+
},
|
|
157
|
+
logLevel,
|
|
158
|
+
extra: {
|
|
159
|
+
KANDEV_DATABASE_PATH: dbPath,
|
|
160
|
+
...(debug ? { KANDEV_DEBUG_AGENT_MESSAGES: "true", KANDEV_DEBUG_PPROF_ENABLED: "true" } : {}),
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
const webEnv = (0, shared_1.buildWebEnv)({
|
|
164
|
+
ports: {
|
|
165
|
+
backendPort: actualBackendPort,
|
|
166
|
+
webPort: actualWebPort,
|
|
167
|
+
agentctlPort,
|
|
168
|
+
backendUrl,
|
|
169
|
+
},
|
|
170
|
+
production: true,
|
|
171
|
+
debug,
|
|
172
|
+
});
|
|
163
173
|
return {
|
|
164
174
|
bundleDir,
|
|
165
175
|
backendBin,
|
package/dist/service/args.js
CHANGED
|
@@ -11,6 +11,7 @@ const VALID_ACTIONS = new Set([
|
|
|
11
11
|
"status",
|
|
12
12
|
"logs",
|
|
13
13
|
"config",
|
|
14
|
+
"self-update",
|
|
14
15
|
]);
|
|
15
16
|
function parseServiceArgs(argv) {
|
|
16
17
|
if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") {
|
|
@@ -39,6 +40,22 @@ function parseServiceArgs(argv) {
|
|
|
39
40
|
out.follow = true;
|
|
40
41
|
continue;
|
|
41
42
|
}
|
|
43
|
+
if (arg === "--dry-run") {
|
|
44
|
+
out.dryRun = true;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (arg === "--intent") {
|
|
48
|
+
out.intent = takeValue(argv, i, "--intent");
|
|
49
|
+
i += 1;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (arg.startsWith("--intent=")) {
|
|
53
|
+
const value = arg.slice("--intent=".length);
|
|
54
|
+
if (value.length === 0)
|
|
55
|
+
throw new args_1.ParseError("--intent requires a value");
|
|
56
|
+
out.intent = value;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
42
59
|
if (arg === "--port") {
|
|
43
60
|
out.port = parsePort(takeValue(argv, i, "--port"), "--port");
|
|
44
61
|
i += 1;
|
|
@@ -75,6 +92,15 @@ function validateActionFlags(args) {
|
|
|
75
92
|
if (args.follow && args.action !== "logs") {
|
|
76
93
|
throw new args_1.ParseError(`--follow only applies to 'kandev service logs', not '${args.action}'`);
|
|
77
94
|
}
|
|
95
|
+
if (args.dryRun && args.action !== "self-update") {
|
|
96
|
+
throw new args_1.ParseError(`--dry-run only applies to 'kandev service self-update', not '${args.action}'`);
|
|
97
|
+
}
|
|
98
|
+
if (args.intent && args.action !== "self-update") {
|
|
99
|
+
throw new args_1.ParseError(`--intent only applies to 'kandev service self-update', not '${args.action}'`);
|
|
100
|
+
}
|
|
101
|
+
if (args.action === "self-update" && !args.showHelp && !args.intent) {
|
|
102
|
+
throw new args_1.ParseError("kandev service self-update requires --intent <path>");
|
|
103
|
+
}
|
|
78
104
|
const installOnly = ["port", "homeDir", "noBootStart"];
|
|
79
105
|
if (args.action !== "install") {
|
|
80
106
|
for (const flag of installOnly) {
|
package/dist/service/index.js
CHANGED
|
@@ -6,6 +6,7 @@ const args_1 = require("./args");
|
|
|
6
6
|
const config_1 = require("./config");
|
|
7
7
|
const linux_1 = require("./linux");
|
|
8
8
|
const macos_1 = require("./macos");
|
|
9
|
+
const self_update_1 = require("./self_update");
|
|
9
10
|
function printServiceHelp() {
|
|
10
11
|
console.log(`kandev service — install kandev as an OS-managed service
|
|
11
12
|
|
|
@@ -51,6 +52,12 @@ async function runServiceCommand(argv) {
|
|
|
51
52
|
(0, config_1.printServiceConfig)(args);
|
|
52
53
|
return;
|
|
53
54
|
}
|
|
55
|
+
// Hidden helper entrypoint used by the backend self-update endpoint. It is
|
|
56
|
+
// intentionally absent from help output.
|
|
57
|
+
if (args.action === "self-update") {
|
|
58
|
+
(0, self_update_1.runSelfUpdateCommand)(args);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
54
61
|
switch (process.platform) {
|
|
55
62
|
case "linux":
|
|
56
63
|
return (0, linux_1.runLinuxService)(args);
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.spawnLaunchctl = spawnLaunchctl;
|
|
4
|
+
exports.sleepSync = sleepSync;
|
|
5
|
+
exports.bootoutAndWait = bootoutAndWait;
|
|
6
|
+
exports.bootstrapWithRetry = bootstrapWithRetry;
|
|
7
|
+
exports.reloadService = reloadService;
|
|
8
|
+
const node_child_process_1 = require("node:child_process");
|
|
9
|
+
const BOOTOUT_POLL_INTERVAL_MS = 100;
|
|
10
|
+
const BOOTOUT_POLL_ATTEMPTS = 50; // ~5s ceiling waiting for teardown
|
|
11
|
+
const BOOTSTRAP_MAX_ATTEMPTS = 5;
|
|
12
|
+
const BOOTSTRAP_RETRY_BASE_MS = 300;
|
|
13
|
+
function spawnLaunchctl(args, stdio) {
|
|
14
|
+
const res = (0, node_child_process_1.spawnSync)("launchctl", args, { stdio });
|
|
15
|
+
return { status: res.status };
|
|
16
|
+
}
|
|
17
|
+
// Block the current thread for `ms`. The launchctl orchestration runs in the
|
|
18
|
+
// synchronous `service install`/`start`/`restart` paths, so we can't await a
|
|
19
|
+
// timer — Atomics.wait gives a dependency-free blocking sleep.
|
|
20
|
+
function sleepSync(ms) {
|
|
21
|
+
if (ms <= 0)
|
|
22
|
+
return;
|
|
23
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
24
|
+
}
|
|
25
|
+
function isLoaded(target, run) {
|
|
26
|
+
// `launchctl print <target>` exits non-zero once the label is gone.
|
|
27
|
+
return run(["print", target], "ignore").status === 0;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Boot out a launchd job and wait until launchd has fully removed it. A no-op
|
|
31
|
+
* (returns promptly) when the label isn't loaded, so fresh installs aren't
|
|
32
|
+
* slowed down.
|
|
33
|
+
*/
|
|
34
|
+
function bootoutAndWait(target, deps = {}) {
|
|
35
|
+
const run = deps.run ?? spawnLaunchctl;
|
|
36
|
+
const sleep = deps.sleep ?? sleepSync;
|
|
37
|
+
run(["bootout", target], "ignore");
|
|
38
|
+
for (let attempt = 0; attempt < BOOTOUT_POLL_ATTEMPTS; attempt += 1) {
|
|
39
|
+
if (!isLoaded(target, run)) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
sleep(BOOTOUT_POLL_INTERVAL_MS);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Bootstrap a launchd job, retrying through the transient EIO that launchd
|
|
47
|
+
* returns while a previous instance of the same label finishes tearing down.
|
|
48
|
+
* Throws with the last exit code if every attempt fails.
|
|
49
|
+
*/
|
|
50
|
+
function bootstrapWithRetry(domain, plistPath, deps = {}) {
|
|
51
|
+
const run = deps.run ?? spawnLaunchctl;
|
|
52
|
+
const sleep = deps.sleep ?? sleepSync;
|
|
53
|
+
let lastStatus = null;
|
|
54
|
+
for (let attempt = 1; attempt <= BOOTSTRAP_MAX_ATTEMPTS; attempt += 1) {
|
|
55
|
+
lastStatus = run(["bootstrap", domain, plistPath], "inherit").status;
|
|
56
|
+
if (lastStatus === 0) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (attempt < BOOTSTRAP_MAX_ATTEMPTS) {
|
|
60
|
+
sleep(BOOTSTRAP_RETRY_BASE_MS * attempt);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
throw new Error(`launchctl bootstrap ${domain} ${plistPath} failed with code ${lastStatus}`);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Reload a launchd job: fully unload (waiting for teardown) then bootstrap with
|
|
67
|
+
* retry. This is the safe sequence whenever the target might currently be
|
|
68
|
+
* running — installs that refresh a live service, and `start`.
|
|
69
|
+
*/
|
|
70
|
+
function reloadService(target, domain, plistPath, deps = {}) {
|
|
71
|
+
bootoutAndWait(target, deps);
|
|
72
|
+
bootstrapWithRetry(domain, plistPath, deps);
|
|
73
|
+
}
|
package/dist/service/linux.js
CHANGED
|
@@ -9,6 +9,7 @@ const node_fs_1 = __importDefault(require("node:fs"));
|
|
|
9
9
|
const node_path_1 = __importDefault(require("node:path"));
|
|
10
10
|
const health_check_1 = require("./health_check");
|
|
11
11
|
const install_helpers_1 = require("./install_helpers");
|
|
12
|
+
const metadata_1 = require("./metadata");
|
|
12
13
|
const paths_1 = require("./paths");
|
|
13
14
|
const templates_1 = require("./templates");
|
|
14
15
|
function makeCtx(args) {
|
|
@@ -45,6 +46,9 @@ async function runLinuxService(args) {
|
|
|
45
46
|
case "config":
|
|
46
47
|
// Handled by the dispatcher in index.ts before reaching the platform layer.
|
|
47
48
|
throw new Error("unreachable: config action handled in service/index.ts");
|
|
49
|
+
case "self-update":
|
|
50
|
+
// Handled by the dispatcher in index.ts before reaching the platform layer.
|
|
51
|
+
throw new Error("unreachable: self-update action handled in service/index.ts");
|
|
48
52
|
default: {
|
|
49
53
|
const _exhaustive = args.action;
|
|
50
54
|
throw new Error(`unhandled service action: ${_exhaustive}`);
|
|
@@ -59,16 +63,30 @@ function installSync(ctx) {
|
|
|
59
63
|
const launcher = (0, paths_1.captureLauncher)();
|
|
60
64
|
const homeDir = (0, paths_1.resolveHomeDir)(ctx.args.homeDir, ctx.isSystem);
|
|
61
65
|
const logDir = (0, paths_1.resolveLogDir)(homeDir);
|
|
66
|
+
const metadataPath = (0, metadata_1.serviceMetadataPath)(homeDir);
|
|
67
|
+
const mode = ctx.isSystem ? "system" : "user";
|
|
68
|
+
const systemUser = ctx.isSystem ? (0, paths_1.resolveServiceUser)(true) : undefined;
|
|
62
69
|
const unit = (0, templates_1.renderSystemdUnit)({
|
|
63
70
|
launcher,
|
|
64
71
|
homeDir,
|
|
65
72
|
logDir,
|
|
66
73
|
port: ctx.args.port,
|
|
67
|
-
systemUser
|
|
68
|
-
mode
|
|
74
|
+
systemUser,
|
|
75
|
+
mode,
|
|
76
|
+
serviceMetadataPath: metadataPath,
|
|
69
77
|
});
|
|
70
78
|
node_fs_1.default.mkdirSync(node_path_1.default.dirname(ctx.unitPath), { recursive: true });
|
|
71
79
|
const outcome = (0, install_helpers_1.writeUnitFile)(ctx.unitPath, unit);
|
|
80
|
+
(0, metadata_1.writeServiceInstallMetadata)(metadataPath, (0, metadata_1.buildServiceInstallMetadata)({
|
|
81
|
+
manager: "systemd",
|
|
82
|
+
mode,
|
|
83
|
+
launcher,
|
|
84
|
+
homeDir,
|
|
85
|
+
logDir,
|
|
86
|
+
servicePath: ctx.unitPath,
|
|
87
|
+
port: ctx.args.port,
|
|
88
|
+
systemUser,
|
|
89
|
+
}));
|
|
72
90
|
runSystemctl(ctx, ["daemon-reload"]);
|
|
73
91
|
// Always run enable --now so 'install' is fully idempotent: if the user
|
|
74
92
|
// manually disabled or stopped the service, re-running install brings it
|
package/dist/service/macos.js
CHANGED
|
@@ -11,6 +11,8 @@ const node_os_1 = __importDefault(require("node:os"));
|
|
|
11
11
|
const node_path_1 = __importDefault(require("node:path"));
|
|
12
12
|
const health_check_1 = require("./health_check");
|
|
13
13
|
const install_helpers_1 = require("./install_helpers");
|
|
14
|
+
const launchctl_1 = require("./launchctl");
|
|
15
|
+
const metadata_1 = require("./metadata");
|
|
14
16
|
const paths_1 = require("./paths");
|
|
15
17
|
const templates_1 = require("./templates");
|
|
16
18
|
function makeCtx(args) {
|
|
@@ -49,6 +51,9 @@ async function runMacosService(args) {
|
|
|
49
51
|
case "config":
|
|
50
52
|
// Handled by the dispatcher in index.ts before reaching the platform layer.
|
|
51
53
|
throw new Error("unreachable: config action handled in service/index.ts");
|
|
54
|
+
case "self-update":
|
|
55
|
+
// Handled by the dispatcher in index.ts before reaching the platform layer.
|
|
56
|
+
throw new Error("unreachable: self-update action handled in service/index.ts");
|
|
52
57
|
default: {
|
|
53
58
|
const _exhaustive = args.action;
|
|
54
59
|
throw new Error(`unhandled service action: ${_exhaustive}`);
|
|
@@ -63,23 +68,41 @@ function installSync(ctx) {
|
|
|
63
68
|
const launcher = (0, paths_1.captureLauncher)();
|
|
64
69
|
const homeDir = (0, paths_1.resolveHomeDir)(ctx.args.homeDir, ctx.isSystem);
|
|
65
70
|
const logDir = (0, paths_1.resolveLogDir)(homeDir);
|
|
71
|
+
const metadataPath = (0, metadata_1.serviceMetadataPath)(homeDir);
|
|
72
|
+
const mode = ctx.isSystem ? "system" : "user";
|
|
73
|
+
const systemUser = ctx.isSystem ? (0, paths_1.resolveServiceUser)(true) : undefined;
|
|
66
74
|
node_fs_1.default.mkdirSync(logDir, { recursive: true });
|
|
67
75
|
const plist = (0, templates_1.renderLaunchdPlist)({
|
|
68
76
|
launcher,
|
|
69
77
|
homeDir,
|
|
70
78
|
logDir,
|
|
71
79
|
port: ctx.args.port,
|
|
72
|
-
systemUser
|
|
73
|
-
mode
|
|
80
|
+
systemUser,
|
|
81
|
+
mode,
|
|
82
|
+
serviceMetadataPath: metadataPath,
|
|
74
83
|
});
|
|
75
84
|
node_fs_1.default.mkdirSync(node_path_1.default.dirname(ctx.plistPath), { recursive: true });
|
|
76
85
|
const outcome = (0, install_helpers_1.writeUnitFile)(ctx.plistPath, plist);
|
|
86
|
+
(0, metadata_1.writeServiceInstallMetadata)(metadataPath, (0, metadata_1.buildServiceInstallMetadata)({
|
|
87
|
+
manager: "launchd",
|
|
88
|
+
mode,
|
|
89
|
+
launcher,
|
|
90
|
+
homeDir,
|
|
91
|
+
logDir,
|
|
92
|
+
servicePath: ctx.plistPath,
|
|
93
|
+
port: ctx.args.port,
|
|
94
|
+
systemUser,
|
|
95
|
+
}));
|
|
77
96
|
// launchctl bootstrap fails if the label is already loaded — bootout first
|
|
78
97
|
// (ignoring its error if nothing was loaded). This means 'install' is
|
|
79
98
|
// idempotent: re-running it reloads the unit even if the file is unchanged,
|
|
80
99
|
// which is how we recover from a user manually unloading the service.
|
|
81
|
-
|
|
82
|
-
|
|
100
|
+
//
|
|
101
|
+
// `reloadService` waits for bootout's async teardown before bootstrapping and
|
|
102
|
+
// retries the transient EIO ("Bootstrap failed: 5") that launchd returns while
|
|
103
|
+
// a still-running instance is torn down — the failure that broke self-update,
|
|
104
|
+
// where this install runs against a live service. See launchctl.ts.
|
|
105
|
+
(0, launchctl_1.reloadService)(ctx.target, ctx.domain, ctx.plistPath);
|
|
83
106
|
runLaunchctl(["enable", ctx.target], { allowFailure: true });
|
|
84
107
|
console.log(outcome === "unchanged"
|
|
85
108
|
? "[kandev] service is loaded and running"
|
|
@@ -107,9 +130,9 @@ function uninstall(ctx) {
|
|
|
107
130
|
// so each begins with a bootout-then-bootstrap dance similar to installSync.
|
|
108
131
|
function startService(ctx) {
|
|
109
132
|
// Idempotent: if the label is already loaded, bootstrap would fail. Bootout
|
|
110
|
-
// first so start works whether the service was
|
|
111
|
-
|
|
112
|
-
|
|
133
|
+
// first (waiting for teardown) so start works whether the service was
|
|
134
|
+
// previously running or stopped.
|
|
135
|
+
(0, launchctl_1.reloadService)(ctx.target, ctx.domain, ctx.plistPath);
|
|
113
136
|
}
|
|
114
137
|
function stopService(ctx) {
|
|
115
138
|
runLaunchctl(["bootout", ctx.target], { allowFailure: true });
|
|
@@ -121,7 +144,9 @@ function restartService(ctx) {
|
|
|
121
144
|
const res = (0, node_child_process_1.spawnSync)("launchctl", ["kickstart", "-k", ctx.target], { stdio: "inherit" });
|
|
122
145
|
if (res.status === 0)
|
|
123
146
|
return;
|
|
124
|
-
|
|
147
|
+
// kickstart fails when the job isn't loaded — reload it (waiting for any
|
|
148
|
+
// residual teardown and retrying the transient bootstrap EIO).
|
|
149
|
+
(0, launchctl_1.reloadService)(ctx.target, ctx.domain, ctx.plistPath);
|
|
125
150
|
}
|
|
126
151
|
function showStatus(ctx) {
|
|
127
152
|
const res = (0, node_child_process_1.spawnSync)("launchctl", ["print", ctx.target], {
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.serviceMetadataPath = serviceMetadataPath;
|
|
7
|
+
exports.buildServiceInstallMetadata = buildServiceInstallMetadata;
|
|
8
|
+
exports.writeServiceInstallMetadata = writeServiceInstallMetadata;
|
|
9
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
10
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
11
|
+
function serviceMetadataPath(homeDir) {
|
|
12
|
+
return node_path_1.default.join(homeDir, "service", "install.json");
|
|
13
|
+
}
|
|
14
|
+
function buildServiceInstallMetadata(input) {
|
|
15
|
+
const out = {
|
|
16
|
+
version: 1,
|
|
17
|
+
manager: input.manager,
|
|
18
|
+
mode: input.mode,
|
|
19
|
+
kind: input.launcher.kind,
|
|
20
|
+
home_dir: input.homeDir,
|
|
21
|
+
log_dir: input.logDir,
|
|
22
|
+
service_path: input.servicePath,
|
|
23
|
+
node_path: input.launcher.nodePath,
|
|
24
|
+
cli_entry: input.launcher.cliEntry,
|
|
25
|
+
installed_at: (input.now ?? new Date()).toISOString(),
|
|
26
|
+
};
|
|
27
|
+
if (input.launcher.bundleDir)
|
|
28
|
+
out.bundle_dir = input.launcher.bundleDir;
|
|
29
|
+
if (input.launcher.version)
|
|
30
|
+
out.launcher_version = input.launcher.version;
|
|
31
|
+
if (input.port !== undefined)
|
|
32
|
+
out.port = input.port;
|
|
33
|
+
if (input.systemUser)
|
|
34
|
+
out.system_user = input.systemUser;
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
function writeServiceInstallMetadata(metadataPath, metadata) {
|
|
38
|
+
const dir = node_path_1.default.dirname(metadataPath);
|
|
39
|
+
// `mode` on mkdir/writeFile only applies when the path is created. chmod after
|
|
40
|
+
// so a pre-existing service dir / install.json is tightened to owner-only too
|
|
41
|
+
// (it can hold launcher paths and the metadata that gates self-update).
|
|
42
|
+
node_fs_1.default.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
43
|
+
node_fs_1.default.chmodSync(dir, 0o700);
|
|
44
|
+
node_fs_1.default.writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, { mode: 0o600 });
|
|
45
|
+
node_fs_1.default.chmodSync(metadataPath, 0o600);
|
|
46
|
+
}
|
package/dist/service/paths.js
CHANGED
|
@@ -75,8 +75,8 @@ function homebrewShimPath(cliEntry) {
|
|
|
75
75
|
*
|
|
76
76
|
* The unit file hard-codes absolute paths because systemd/launchd start with an
|
|
77
77
|
* empty PATH and may not see the user's `node` or `kandev` shim. By recording
|
|
78
|
-
* `process.execPath` (node) and
|
|
79
|
-
*
|
|
78
|
+
* `process.execPath` (node) and the resolved CLI entry at install time we avoid
|
|
79
|
+
* any PATH lookups at service-run time.
|
|
80
80
|
*/
|
|
81
81
|
function captureLauncher() {
|
|
82
82
|
const nodePath = process.execPath;
|
|
@@ -85,9 +85,11 @@ function captureLauncher() {
|
|
|
85
85
|
const version = process.env.KANDEV_VERSION;
|
|
86
86
|
const kind = bundleDir
|
|
87
87
|
? "homebrew"
|
|
88
|
-
: cliEntry
|
|
89
|
-
? "
|
|
90
|
-
:
|
|
88
|
+
: looksLikeNpxEntry(cliEntry)
|
|
89
|
+
? "npx"
|
|
90
|
+
: cliEntry.includes(`${node_path_1.default.sep}node_modules${node_path_1.default.sep}`)
|
|
91
|
+
? "npm"
|
|
92
|
+
: "unknown";
|
|
91
93
|
// For Homebrew installs, prefer the floating bin shim so the unit survives
|
|
92
94
|
// `brew upgrade` (which deletes the versioned Cellar dir baked into nodePath
|
|
93
95
|
// /cliEntry). Only adopt it when the shim actually exists on disk; otherwise
|
|
@@ -100,10 +102,13 @@ function captureLauncher() {
|
|
|
100
102
|
}
|
|
101
103
|
return { nodePath, cliEntry, kind, bundleDir, version, shimPath };
|
|
102
104
|
}
|
|
105
|
+
function looksLikeNpxEntry(cliEntry) {
|
|
106
|
+
return cliEntry.includes(`${node_path_1.default.sep}_npx${node_path_1.default.sep}`);
|
|
107
|
+
}
|
|
103
108
|
function resolveCliEntry() {
|
|
104
109
|
const argvEntry = process.argv[1];
|
|
105
110
|
if (argvEntry && node_fs_1.default.existsSync(argvEntry)) {
|
|
106
|
-
return node_path_1.default.resolve(argvEntry);
|
|
111
|
+
return node_path_1.default.resolve(node_fs_1.default.realpathSync(argvEntry));
|
|
107
112
|
}
|
|
108
113
|
throw new Error("could not resolve the kandev CLI entry path from process.argv[1]; " +
|
|
109
114
|
"rerun via the kandev binary");
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.readSelfUpdateIntent = readSelfUpdateIntent;
|
|
7
|
+
exports.planSelfUpdate = planSelfUpdate;
|
|
8
|
+
exports.runSelfUpdateCommand = runSelfUpdateCommand;
|
|
9
|
+
const node_child_process_1 = require("node:child_process");
|
|
10
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
11
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
12
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
13
|
+
const paths_1 = require("./paths");
|
|
14
|
+
function readSelfUpdateIntent(intentPath) {
|
|
15
|
+
return JSON.parse(node_fs_1.default.readFileSync(intentPath, "utf8"));
|
|
16
|
+
}
|
|
17
|
+
function planSelfUpdate(intent, opts = {}) {
|
|
18
|
+
const platform = opts.platform ?? process.platform;
|
|
19
|
+
const target = npmVersion(intent.target_version || intent.target_tag);
|
|
20
|
+
const install = intent.install;
|
|
21
|
+
const installArgs = serviceInstallArgs(install);
|
|
22
|
+
const commands = [];
|
|
23
|
+
if (install.kind === "homebrew") {
|
|
24
|
+
// `brew upgrade` installs the tap formula's current version; it can't be
|
|
25
|
+
// pinned to intent.target_version the way npm/npx can (Homebrew has no
|
|
26
|
+
// stable "install version X" without a separate versioned formula). In
|
|
27
|
+
// practice both target_version and the formula derive from the same latest
|
|
28
|
+
// GitHub release, so they match. If the formula lags, the restarted backend
|
|
29
|
+
// reports the older version and the frontend progress poll (which waits for
|
|
30
|
+
// info.version === target_version) times out gracefully rather than
|
|
31
|
+
// reporting a false success.
|
|
32
|
+
commands.push({ command: "brew", args: ["upgrade", "kandev"] });
|
|
33
|
+
// Re-run service install via the upgraded `kandev` wrapper (resolved on
|
|
34
|
+
// PATH), NOT `node <cli_entry>`. Homebrew installs into version-pinned
|
|
35
|
+
// Cellar dirs, so the recorded cli_entry still points at the OLD bundle
|
|
36
|
+
// after `brew upgrade`; worse, invoking node on it directly runs without
|
|
37
|
+
// KANDEV_BUNDLE_DIR/KANDEV_VERSION, so the install is mis-detected as
|
|
38
|
+
// kind "unknown" and the regenerated unit loses the bundle env — the
|
|
39
|
+
// restarted backend then can't find its runtime and the service stays down.
|
|
40
|
+
// The wrapper is version-stable and re-sets the bundle env. (Mirrors the
|
|
41
|
+
// manual `kandev service install` we already document for Homebrew.)
|
|
42
|
+
commands.push({ command: "kandev", args: installArgs });
|
|
43
|
+
}
|
|
44
|
+
else if (install.kind === "npm") {
|
|
45
|
+
commands.push({ command: "npm", args: npmInstallArgs(install.cli_entry, target) });
|
|
46
|
+
commands.push({ command: install.node_path, args: [install.cli_entry, ...installArgs] });
|
|
47
|
+
}
|
|
48
|
+
else if (install.kind === "npx") {
|
|
49
|
+
commands.push({ command: "npx", args: ["-y", `kandev@${target}`, ...installArgs] });
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
throw new Error(`unsupported install kind "${install.kind}"`);
|
|
53
|
+
}
|
|
54
|
+
commands.push(restartCommand(install, platform, opts.uid));
|
|
55
|
+
return commands;
|
|
56
|
+
}
|
|
57
|
+
function runSelfUpdateCommand(args, runner = spawnCommand) {
|
|
58
|
+
if (!args.intent) {
|
|
59
|
+
throw new Error("kandev service self-update requires --intent <path>");
|
|
60
|
+
}
|
|
61
|
+
const intent = readSelfUpdateIntent(args.intent);
|
|
62
|
+
const commands = planSelfUpdate(intent);
|
|
63
|
+
if (args.dryRun || process.env.KANDEV_E2E_MOCK === "true") {
|
|
64
|
+
console.log(JSON.stringify({
|
|
65
|
+
dry_run: !!args.dryRun,
|
|
66
|
+
fake: process.env.KANDEV_E2E_MOCK === "true",
|
|
67
|
+
target_version: intent.target_version,
|
|
68
|
+
commands,
|
|
69
|
+
}, null, 2));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const log = openSelfUpdateLog(intent);
|
|
73
|
+
try {
|
|
74
|
+
log?.line(`self-update target ${intent.target_tag} (${intent.target_version})`);
|
|
75
|
+
log?.line(`install kind=${intent.install.kind} manager=${intent.install.manager}`);
|
|
76
|
+
log?.line(`planned ${commands.length} command(s):`);
|
|
77
|
+
commands.forEach((step, i) => log?.line(` [${i + 1}/${commands.length}] ${formatCommand(step)}`));
|
|
78
|
+
runSelfUpdateSteps(commands, runner, log);
|
|
79
|
+
log?.line("self-update completed successfully");
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
log?.close();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function runSelfUpdateSteps(commands, runner, log) {
|
|
86
|
+
for (const [i, step] of commands.entries()) {
|
|
87
|
+
log?.line(`\n[${i + 1}/${commands.length}] $ ${formatCommand(step)}`);
|
|
88
|
+
const res = runner(step.command, step.args);
|
|
89
|
+
teeCommandOutput(log, res);
|
|
90
|
+
if (res.error) {
|
|
91
|
+
log?.line(`! spawn error: ${res.error.message}`);
|
|
92
|
+
throw res.error;
|
|
93
|
+
}
|
|
94
|
+
if (res.status !== 0) {
|
|
95
|
+
log?.line(`! exited with code ${res.status}`);
|
|
96
|
+
throw new Error(`${formatCommand(step)} failed with code ${res.status}`);
|
|
97
|
+
}
|
|
98
|
+
log?.line(" exit 0");
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function formatCommand(step) {
|
|
102
|
+
return [step.command, ...step.args].join(" ");
|
|
103
|
+
}
|
|
104
|
+
function serviceInstallArgs(install) {
|
|
105
|
+
const args = ["service", "install"];
|
|
106
|
+
if (install.mode === "system")
|
|
107
|
+
args.push("--system");
|
|
108
|
+
args.push("--home-dir", install.home_dir);
|
|
109
|
+
if (install.port !== undefined) {
|
|
110
|
+
args.push("--port", String(install.port));
|
|
111
|
+
}
|
|
112
|
+
return args;
|
|
113
|
+
}
|
|
114
|
+
function npmInstallArgs(cliEntry, target) {
|
|
115
|
+
const args = ["install", "-g"];
|
|
116
|
+
const prefix = npmPrefixFromCliEntry(cliEntry);
|
|
117
|
+
if (prefix) {
|
|
118
|
+
args.push("--prefix", prefix);
|
|
119
|
+
}
|
|
120
|
+
args.push(`kandev@${target}`);
|
|
121
|
+
return args;
|
|
122
|
+
}
|
|
123
|
+
function npmPrefixFromCliEntry(cliEntry) {
|
|
124
|
+
const marker = `${node_path_1.default.sep}lib${node_path_1.default.sep}node_modules${node_path_1.default.sep}kandev${node_path_1.default.sep}`;
|
|
125
|
+
const index = cliEntry.indexOf(marker);
|
|
126
|
+
if (index < 0) {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
return index === 0 ? node_path_1.default.sep : cliEntry.slice(0, index);
|
|
130
|
+
}
|
|
131
|
+
function restartCommand(install, platform, uid) {
|
|
132
|
+
if (platform === "linux") {
|
|
133
|
+
return install.mode === "system"
|
|
134
|
+
? { command: "systemctl", args: ["restart", paths_1.SERVICE_NAME] }
|
|
135
|
+
: { command: "systemctl", args: ["--user", "restart", paths_1.SERVICE_NAME] };
|
|
136
|
+
}
|
|
137
|
+
if (platform === "darwin") {
|
|
138
|
+
// Resolve the uid lazily inside the darwin branch — Linux never needs it, so
|
|
139
|
+
// os.userInfo() shouldn't run there.
|
|
140
|
+
const resolvedUid = uid ?? node_os_1.default.userInfo().uid;
|
|
141
|
+
const domain = install.mode === "system" ? "system" : `gui/${resolvedUid}`;
|
|
142
|
+
return { command: "launchctl", args: ["kickstart", "-k", `${domain}/${paths_1.LAUNCHD_LABEL}`] };
|
|
143
|
+
}
|
|
144
|
+
throw new Error(`unsupported platform "${platform}"`);
|
|
145
|
+
}
|
|
146
|
+
function npmVersion(versionOrTag) {
|
|
147
|
+
const stripped = versionOrTag.replace(/^v/, "");
|
|
148
|
+
return stripped || "latest";
|
|
149
|
+
}
|
|
150
|
+
function spawnCommand(command, args) {
|
|
151
|
+
// Capture stdout/stderr (rather than inheriting) so the self-update log can
|
|
152
|
+
// tee each step's output. The helper runs detached under launchd/systemd, so
|
|
153
|
+
// its console output would otherwise go nowhere visible — the log file is the
|
|
154
|
+
// only forensic trail when an update fails mid-flight. maxBuffer is bumped
|
|
155
|
+
// well above the 1 MiB default because `npm install -g` is chatty.
|
|
156
|
+
return (0, node_child_process_1.spawnSync)(command, args, { maxBuffer: 64 * 1024 * 1024 });
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Open a timestamped log file under the service's log dir for this self-update
|
|
160
|
+
* run. The helper is spawned outside the service process (so a restart doesn't
|
|
161
|
+
* kill it mid-update), which means it has no service log of its own — without
|
|
162
|
+
* this file a failed update on macOS/launchd is nearly invisible. Best-effort:
|
|
163
|
+
* if the log dir can't be created we return null and the update still runs.
|
|
164
|
+
*/
|
|
165
|
+
function openSelfUpdateLog(intent) {
|
|
166
|
+
const dir = intent.install.log_dir || logDirFromHome(intent.install.home_dir);
|
|
167
|
+
if (!dir) {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
node_fs_1.default.mkdirSync(dir, { recursive: true });
|
|
172
|
+
// Self-update logs can contain install paths/env; keep the dir owner-only
|
|
173
|
+
// even if it pre-existed with looser perms.
|
|
174
|
+
node_fs_1.default.chmodSync(dir, 0o700);
|
|
175
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
176
|
+
const filePath = node_path_1.default.join(dir, `self-update-${stamp}.log`);
|
|
177
|
+
const fd = node_fs_1.default.openSync(filePath, "a");
|
|
178
|
+
const write = (chunk) => {
|
|
179
|
+
try {
|
|
180
|
+
// The `Buffer | string` union matches neither writeSync overload
|
|
181
|
+
// directly; the cast picks one — both write the runtime value correctly.
|
|
182
|
+
node_fs_1.default.writeSync(fd, chunk);
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
// A write failure mid-update must not abort the update itself.
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
process.stdout.write(`[kandev] self-update log: ${filePath}\n`);
|
|
189
|
+
write(`# kandev self-update ${new Date().toISOString()}\n`);
|
|
190
|
+
return {
|
|
191
|
+
path: filePath,
|
|
192
|
+
line: (message) => write(`${message}\n`),
|
|
193
|
+
raw: (chunk) => write(chunk),
|
|
194
|
+
close: () => {
|
|
195
|
+
try {
|
|
196
|
+
node_fs_1.default.closeSync(fd);
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
// already closed / never opened
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
function logDirFromHome(homeDir) {
|
|
209
|
+
return homeDir ? node_path_1.default.join(homeDir, "logs") : "";
|
|
210
|
+
}
|
|
211
|
+
// Mirror a command's captured output to both the helper log and the process's
|
|
212
|
+
// own stdout/stderr, so whatever does manage to capture the helper's streams
|
|
213
|
+
// (e.g. launchd's StandardOut/ErrorPath, systemd's journal) still sees it.
|
|
214
|
+
function teeCommandOutput(log, res) {
|
|
215
|
+
if (res.stdout && res.stdout.length) {
|
|
216
|
+
process.stdout.write(res.stdout);
|
|
217
|
+
log?.raw(res.stdout);
|
|
218
|
+
}
|
|
219
|
+
if (res.stderr && res.stderr.length) {
|
|
220
|
+
process.stderr.write(res.stderr);
|
|
221
|
+
log?.raw(res.stderr);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
@@ -82,6 +82,9 @@ function renderSystemdUnit(input) {
|
|
|
82
82
|
if (!shimPath && input.launcher.version) {
|
|
83
83
|
env.push(envLine("KANDEV_VERSION", input.launcher.version));
|
|
84
84
|
}
|
|
85
|
+
if (input.serviceMetadataPath) {
|
|
86
|
+
env.push(...serviceEnvLines(input, "systemd"));
|
|
87
|
+
}
|
|
85
88
|
const wantedBy = input.mode === "system" ? "multi-user.target" : "default.target";
|
|
86
89
|
const userLine = input.mode === "system" && input.systemUser ? `User=${input.systemUser}\n` : "";
|
|
87
90
|
const exec = shimPath
|
|
@@ -133,6 +136,9 @@ function renderLaunchdPlist(input) {
|
|
|
133
136
|
if (!shimPath && input.launcher.version) {
|
|
134
137
|
envEntries.push(["KANDEV_VERSION", input.launcher.version]);
|
|
135
138
|
}
|
|
139
|
+
if (input.serviceMetadataPath) {
|
|
140
|
+
envEntries.push(...serviceEnvEntries(input, "launchd"));
|
|
141
|
+
}
|
|
136
142
|
const envXml = envEntries
|
|
137
143
|
.map(([k, v]) => ` <key>${escapeXml(k)}</key>\n <string>${escapeXml(v)}</string>`)
|
|
138
144
|
.join("\n");
|
|
@@ -205,3 +211,17 @@ function envLine(key, value) {
|
|
|
205
211
|
const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
206
212
|
return `Environment="${key}=${escaped}"`;
|
|
207
213
|
}
|
|
214
|
+
function serviceEnvLines(input, manager) {
|
|
215
|
+
return serviceEnvEntries(input, manager).map(([key, value]) => envLine(key, value));
|
|
216
|
+
}
|
|
217
|
+
function serviceEnvEntries(input, manager) {
|
|
218
|
+
if (!input.serviceMetadataPath)
|
|
219
|
+
return [];
|
|
220
|
+
return [
|
|
221
|
+
["KANDEV_RUNNING_AS_SERVICE", "true"],
|
|
222
|
+
["KANDEV_SERVICE_MODE", input.mode],
|
|
223
|
+
["KANDEV_SERVICE_MANAGER", manager],
|
|
224
|
+
["KANDEV_INSTALL_KIND", input.launcher.kind],
|
|
225
|
+
["KANDEV_SERVICE_METADATA", input.serviceMetadataPath],
|
|
226
|
+
];
|
|
227
|
+
}
|
package/dist/shared.js
CHANGED
package/dist/start.js
CHANGED
|
@@ -126,7 +126,11 @@ async function runStart({ repoRoot, backendPort, webPort, verbose = false, debug
|
|
|
126
126
|
}
|
|
127
127
|
// Production mode: use warn log level for clean output unless verbose/debug
|
|
128
128
|
const showOutput = verbose || debug;
|
|
129
|
-
|
|
129
|
+
node_fs_1.default.mkdirSync((0, constants_1.resolveDataDir)(), { recursive: true });
|
|
130
|
+
// The data dir holds the SQLite DB; keep it owner-only even if it pre-existed
|
|
131
|
+
// with a looser umask-derived mode.
|
|
132
|
+
node_fs_1.default.chmodSync((0, constants_1.resolveDataDir)(), 0o700);
|
|
133
|
+
const dbPath = (0, constants_1.resolveDatabasePath)();
|
|
130
134
|
const logLevel = process.env.KANDEV_LOG_LEVEL?.trim() || (debug ? "debug" : verbose ? "info" : "warn");
|
|
131
135
|
const backendEnv = (0, shared_1.buildBackendEnv)({
|
|
132
136
|
ports,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kandev",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.55.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Launcher for Kandev — manage tasks, orchestrate agents, review changes, and ship value",
|
|
6
6
|
"license": "AGPL-3.0-only",
|
|
@@ -22,11 +22,11 @@
|
|
|
22
22
|
"npm": ">=7"
|
|
23
23
|
},
|
|
24
24
|
"optionalDependencies": {
|
|
25
|
-
"@kdlbs/runtime-linux-x64": "0.
|
|
26
|
-
"@kdlbs/runtime-linux-arm64": "0.
|
|
27
|
-
"@kdlbs/runtime-darwin-x64": "0.
|
|
28
|
-
"@kdlbs/runtime-darwin-arm64": "0.
|
|
29
|
-
"@kdlbs/runtime-win32-x64": "0.
|
|
25
|
+
"@kdlbs/runtime-linux-x64": "0.55.0",
|
|
26
|
+
"@kdlbs/runtime-linux-arm64": "0.55.0",
|
|
27
|
+
"@kdlbs/runtime-darwin-x64": "0.55.0",
|
|
28
|
+
"@kdlbs/runtime-darwin-arm64": "0.55.0",
|
|
29
|
+
"@kdlbs/runtime-win32-x64": "0.55.0"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"tar": "^7.5.11",
|