akm-cli 0.9.0-rc.3 → 0.9.0-rc.4
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/CHANGELOG.md +6 -4
- package/README.md +7 -8
- package/SECURITY.md +5 -3
- package/dist/akm +44 -33
- package/dist/akm-migrate-storage +44 -33
- package/dist/assets/backends/schtasks-template.xml +2 -1
- package/dist/cli.js +25 -10
- package/dist/commands/health/html-report.js +23 -2
- package/dist/commands/health/improve-metrics.js +45 -6
- package/dist/commands/health/md-report.js +10 -1
- package/dist/commands/health/windows.js +1 -1
- package/dist/commands/health.js +1 -1
- package/dist/commands/tasks/default-tasks.js +5 -5
- package/dist/commands/tasks/tasks-cli.js +7 -3
- package/dist/commands/tasks/tasks.js +261 -68
- package/dist/core/improve-result.js +87 -7
- package/dist/core/state-db.js +1 -0
- package/dist/scripts/migrate-storage.js +21 -3
- package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +89 -8
- package/dist/storage/repositories/task-history-repository.js +30 -3
- package/dist/tasks/backends/cron.js +71 -38
- package/dist/tasks/backends/exec-utils.js +55 -3
- package/dist/tasks/backends/launchd.js +241 -50
- package/dist/tasks/backends/schtasks.js +434 -59
- package/dist/tasks/command-executable.js +93 -0
- package/dist/tasks/parser.js +142 -6
- package/dist/tasks/resolve-akm-bin.js +19 -4
- package/dist/tasks/runner.js +258 -93
- package/dist/tasks/schedule.js +108 -19
- package/dist/tasks/scheduler-invocation.js +86 -0
- package/dist/tasks/task-id.js +37 -0
- package/docs/README.md +103 -0
- package/docs/migration/release-notes/0.9.0.md +4 -2
- package/docs/migration/v0.8-to-v0.9.md +64 -9
- package/package.json +2 -3
|
@@ -16963,8 +16963,26 @@ var COMMON_FIELDS = [
|
|
|
16963
16963
|
"sync",
|
|
16964
16964
|
"terminated"
|
|
16965
16965
|
];
|
|
16966
|
-
var V1_FIELDS = new Set([...COMMON_FIELDS, "profile", "profileFilteredRefs"]);
|
|
16966
|
+
var V1_FIELDS = new Set([...COMMON_FIELDS, "profile", "profileFilteredRefs", "stalenessDetection"]);
|
|
16967
16967
|
var V2_FIELDS = new Set([...COMMON_FIELDS, "strategy", "strategyFilteredRefs"]);
|
|
16968
|
+
var STALENESS_DETECTION_FIELDS = new Set([
|
|
16969
|
+
"considered",
|
|
16970
|
+
"deprecated",
|
|
16971
|
+
"confirmed",
|
|
16972
|
+
"skipped",
|
|
16973
|
+
"durationMs",
|
|
16974
|
+
"warnings"
|
|
16975
|
+
]);
|
|
16976
|
+
var INTERRUPTED_V1_FIELDS = new Set([
|
|
16977
|
+
"schemaVersion",
|
|
16978
|
+
"ok",
|
|
16979
|
+
"profile",
|
|
16980
|
+
"scope",
|
|
16981
|
+
"dryRun",
|
|
16982
|
+
"plannedRefs",
|
|
16983
|
+
"actions",
|
|
16984
|
+
"terminated"
|
|
16985
|
+
]);
|
|
16968
16986
|
function fail(message) {
|
|
16969
16987
|
throw new Error(`invalid improve-result envelope: ${message}`);
|
|
16970
16988
|
}
|
|
@@ -16976,6 +16994,33 @@ function requireExactFields(value, allowed) {
|
|
|
16976
16994
|
if (unknown.length > 0)
|
|
16977
16995
|
fail(`unknown field${unknown.length === 1 ? "" : "s"}: ${unknown.sort().join(", ")}`);
|
|
16978
16996
|
}
|
|
16997
|
+
function isKnownInterruptedV1Partial(value) {
|
|
16998
|
+
if (Object.keys(value).some((key) => !INTERRUPTED_V1_FIELDS.has(key)))
|
|
16999
|
+
return false;
|
|
17000
|
+
if (value.ok !== false || typeof value.dryRun !== "boolean")
|
|
17001
|
+
return false;
|
|
17002
|
+
if (value.profile !== undefined && typeof value.profile !== "string")
|
|
17003
|
+
return false;
|
|
17004
|
+
if (!Array.isArray(value.plannedRefs) || value.plannedRefs.length !== 0)
|
|
17005
|
+
return false;
|
|
17006
|
+
if (!Array.isArray(value.actions) || value.actions.length !== 0)
|
|
17007
|
+
return false;
|
|
17008
|
+
if (!isRecord(value.scope))
|
|
17009
|
+
return false;
|
|
17010
|
+
if (Object.keys(value.scope).some((key) => key !== "mode" && key !== "value"))
|
|
17011
|
+
return false;
|
|
17012
|
+
if (value.scope.mode !== "all" && value.scope.mode !== "type" && value.scope.mode !== "ref")
|
|
17013
|
+
return false;
|
|
17014
|
+
if (value.scope.value !== undefined && typeof value.scope.value !== "string")
|
|
17015
|
+
return false;
|
|
17016
|
+
if (!isRecord(value.terminated))
|
|
17017
|
+
return false;
|
|
17018
|
+
if (Object.keys(value.terminated).some((key) => !["reason", "at", "errorMessage"].includes(key)))
|
|
17019
|
+
return false;
|
|
17020
|
+
if (typeof value.terminated.reason !== "string" || typeof value.terminated.at !== "string")
|
|
17021
|
+
return false;
|
|
17022
|
+
return value.terminated.errorMessage === undefined || typeof value.terminated.errorMessage === "string";
|
|
17023
|
+
}
|
|
16979
17024
|
function validateCommon(value) {
|
|
16980
17025
|
if (typeof value.ok !== "boolean")
|
|
16981
17026
|
fail("ok must be a boolean");
|
|
@@ -17050,6 +17095,32 @@ function validateCommon(value) {
|
|
|
17050
17095
|
if (value[field] !== undefined && !isRecord(value[field]))
|
|
17051
17096
|
fail(`${field} must be an object`);
|
|
17052
17097
|
}
|
|
17098
|
+
if (isRecord(value.terminated)) {
|
|
17099
|
+
requireExactFields(value.terminated, new Set(["reason", "at", "errorMessage"]));
|
|
17100
|
+
if (typeof value.terminated.reason !== "string" || typeof value.terminated.at !== "string") {
|
|
17101
|
+
fail("terminated.reason and terminated.at must be strings");
|
|
17102
|
+
}
|
|
17103
|
+
if (value.terminated.errorMessage !== undefined && typeof value.terminated.errorMessage !== "string") {
|
|
17104
|
+
fail("terminated.errorMessage must be a string when present");
|
|
17105
|
+
}
|
|
17106
|
+
}
|
|
17107
|
+
}
|
|
17108
|
+
function validateV1StalenessDetection(value) {
|
|
17109
|
+
if (value === undefined)
|
|
17110
|
+
return;
|
|
17111
|
+
if (!isRecord(value))
|
|
17112
|
+
fail("stalenessDetection must be an object");
|
|
17113
|
+
const unknown = Object.keys(value).filter((key) => !STALENESS_DETECTION_FIELDS.has(key));
|
|
17114
|
+
if (unknown.length > 0) {
|
|
17115
|
+
fail(`stalenessDetection has unknown field${unknown.length === 1 ? "" : "s"}: ${unknown.sort().join(", ")}`);
|
|
17116
|
+
}
|
|
17117
|
+
for (const field of ["considered", "deprecated", "confirmed", "skipped", "durationMs"]) {
|
|
17118
|
+
if (typeof value[field] !== "number")
|
|
17119
|
+
fail(`stalenessDetection.${field} must be a number`);
|
|
17120
|
+
}
|
|
17121
|
+
if (!Array.isArray(value.warnings) || value.warnings.some((warning) => typeof warning !== "string")) {
|
|
17122
|
+
fail("stalenessDetection.warnings must be an array of strings");
|
|
17123
|
+
}
|
|
17053
17124
|
}
|
|
17054
17125
|
function decodeImproveResult(input) {
|
|
17055
17126
|
let parsed = input;
|
|
@@ -17063,17 +17134,25 @@ function decodeImproveResult(input) {
|
|
|
17063
17134
|
if (!isRecord(parsed))
|
|
17064
17135
|
fail("root must be an object");
|
|
17065
17136
|
if (parsed.schemaVersion === 1) {
|
|
17066
|
-
|
|
17067
|
-
|
|
17068
|
-
if (
|
|
17137
|
+
let normalized = parsed;
|
|
17138
|
+
let normalizedLegacyPartial = false;
|
|
17139
|
+
if (normalized.memorySummary === undefined && isKnownInterruptedV1Partial(normalized)) {
|
|
17140
|
+
normalized = { ...normalized, memorySummary: { eligible: 0, derived: 0 } };
|
|
17141
|
+
normalizedLegacyPartial = true;
|
|
17142
|
+
}
|
|
17143
|
+
requireExactFields(normalized, V1_FIELDS);
|
|
17144
|
+
validateCommon(normalized);
|
|
17145
|
+
validateV1StalenessDetection(normalized.stalenessDetection);
|
|
17146
|
+
if (normalized.profile !== undefined && typeof normalized.profile !== "string")
|
|
17069
17147
|
fail("profile must be a string");
|
|
17070
|
-
if (
|
|
17148
|
+
if (normalized.profileFilteredRefs !== undefined && !Array.isArray(normalized.profileFilteredRefs)) {
|
|
17071
17149
|
fail("profileFilteredRefs must be an array");
|
|
17072
17150
|
}
|
|
17073
17151
|
return {
|
|
17074
|
-
envelope:
|
|
17152
|
+
envelope: normalized,
|
|
17075
17153
|
strategy: null,
|
|
17076
|
-
legacyProfile: typeof
|
|
17154
|
+
legacyProfile: typeof normalized.profile === "string" ? normalized.profile : null,
|
|
17155
|
+
normalizedLegacyPartial
|
|
17077
17156
|
};
|
|
17078
17157
|
}
|
|
17079
17158
|
if (parsed.schemaVersion === 2) {
|
|
@@ -17088,7 +17167,8 @@ function decodeImproveResult(input) {
|
|
|
17088
17167
|
return {
|
|
17089
17168
|
envelope: parsed,
|
|
17090
17169
|
strategy: parsed.strategy,
|
|
17091
|
-
legacyProfile: null
|
|
17170
|
+
legacyProfile: null,
|
|
17171
|
+
normalizedLegacyPartial: false
|
|
17092
17172
|
};
|
|
17093
17173
|
}
|
|
17094
17174
|
fail(`unsupported schemaVersion: ${String(parsed.schemaVersion)}`);
|
|
@@ -17845,6 +17925,7 @@ function openStateDatabase(dbPath) {
|
|
|
17845
17925
|
if (existed) {
|
|
17846
17926
|
const preflight = openDatabase(resolvedPath, { readonly: true });
|
|
17847
17927
|
try {
|
|
17928
|
+
preflight.exec("PRAGMA busy_timeout = 30000");
|
|
17848
17929
|
if (isCanonical)
|
|
17849
17930
|
assertCurrentMigrationLedger(preflight, STATE_MIGRATIONS);
|
|
17850
17931
|
else
|
|
@@ -79,6 +79,33 @@ export function decodeTaskHistoryMetadata(input) {
|
|
|
79
79
|
}
|
|
80
80
|
metadataError(`unsupported metadataVersion: ${String(version)}`);
|
|
81
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* Atomically reserve one task-attempt identity.
|
|
84
|
+
*
|
|
85
|
+
* The existing unique `(task_id, started_at)` index arbitrates concurrent
|
|
86
|
+
* processes: exactly one caller receives `true` for a candidate timestamp.
|
|
87
|
+
* Callers can advance by one millisecond and retry without a schema change.
|
|
88
|
+
*/
|
|
89
|
+
export function reserveTaskHistoryAttempt(db, row) {
|
|
90
|
+
const result = db
|
|
91
|
+
.prepare(`INSERT OR IGNORE INTO task_history
|
|
92
|
+
(task_id, status, started_at, completed_at, failed_at, log_path,
|
|
93
|
+
target_kind, target_ref, metadata_json)
|
|
94
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
95
|
+
.run(row.task_id, row.status, row.started_at, row.completed_at ?? null, row.failed_at ?? null, row.log_path ?? null, row.target_kind ?? null, row.target_ref ?? null, row.metadata_json);
|
|
96
|
+
return Number(result.changes) === 1;
|
|
97
|
+
}
|
|
98
|
+
/** Finalize a row previously created by {@link reserveTaskHistoryAttempt}. */
|
|
99
|
+
export function finalizeTaskHistoryAttempt(db, row) {
|
|
100
|
+
const result = db
|
|
101
|
+
.prepare(`UPDATE task_history
|
|
102
|
+
SET status = ?, completed_at = ?, failed_at = ?, log_path = ?,
|
|
103
|
+
target_kind = ?, target_ref = ?, metadata_json = ?
|
|
104
|
+
WHERE task_id = ? AND started_at = ?
|
|
105
|
+
AND status = 'active' AND completed_at IS NULL`)
|
|
106
|
+
.run(row.status, row.completed_at ?? null, row.failed_at ?? null, row.log_path ?? null, row.target_kind ?? null, row.target_ref ?? null, row.metadata_json, row.task_id, row.started_at);
|
|
107
|
+
return Number(result.changes) === 1;
|
|
108
|
+
}
|
|
82
109
|
/**
|
|
83
110
|
* Upsert a task history row.
|
|
84
111
|
*/
|
|
@@ -102,7 +129,7 @@ export function getTaskHistory(db, taskId) {
|
|
|
102
129
|
return db
|
|
103
130
|
.prepare(`SELECT id, task_id, status, started_at, completed_at, failed_at, log_path,
|
|
104
131
|
target_kind, target_ref, metadata_json
|
|
105
|
-
FROM task_history WHERE task_id = ? ORDER BY started_at DESC LIMIT 1`)
|
|
132
|
+
FROM task_history WHERE task_id = ? ORDER BY started_at DESC, id DESC LIMIT 1`)
|
|
106
133
|
.get(taskId);
|
|
107
134
|
}
|
|
108
135
|
/**
|
|
@@ -112,7 +139,7 @@ export function getTaskHistoryRuns(db, taskId, limit = 50) {
|
|
|
112
139
|
return db
|
|
113
140
|
.prepare(`SELECT id, task_id, status, started_at, completed_at, failed_at, log_path,
|
|
114
141
|
target_kind, target_ref, metadata_json
|
|
115
|
-
FROM task_history WHERE task_id = ? ORDER BY started_at DESC LIMIT ?`)
|
|
142
|
+
FROM task_history WHERE task_id = ? ORDER BY started_at DESC, id DESC LIMIT ?`)
|
|
116
143
|
.all(taskId, limit);
|
|
117
144
|
}
|
|
118
145
|
/**
|
|
@@ -145,7 +172,7 @@ export function queryTaskHistory(db, options = {}) {
|
|
|
145
172
|
return db
|
|
146
173
|
.prepare(`SELECT task_id, status, started_at, completed_at, failed_at, log_path,
|
|
147
174
|
target_kind, target_ref, metadata_json
|
|
148
|
-
FROM task_history ${where} ORDER BY started_at DESC`)
|
|
175
|
+
FROM task_history ${where} ORDER BY started_at DESC, id DESC`)
|
|
149
176
|
.all(...params);
|
|
150
177
|
}
|
|
151
178
|
/**
|
|
@@ -25,60 +25,70 @@
|
|
|
25
25
|
//
|
|
26
26
|
// Tests inject a fake exec so unit tests don't touch the real crontab.
|
|
27
27
|
import { spawnSync } from "node:child_process";
|
|
28
|
-
import fs from "node:fs";
|
|
29
28
|
import path from "node:path";
|
|
30
29
|
import { ConfigError } from "../../core/errors.js";
|
|
31
30
|
import { getTaskLogDir } from "../../core/paths.js";
|
|
32
31
|
import { resolveAkmInvocation } from "../resolve-akm-bin.js";
|
|
33
32
|
import { parseSchedule, translateToCron } from "../schedule.js";
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
import { buildScheduledTaskInvocation, resolveScheduledTaskContext, } from "../scheduler-invocation.js";
|
|
34
|
+
import { nodeFs } from "./exec-utils.js";
|
|
35
|
+
const BEGIN = (id) => `# akm:task ${assertCronValue(id)} BEGIN`;
|
|
36
|
+
const END = (id) => `# akm:task ${assertCronValue(id)} END`;
|
|
36
37
|
const DISABLED_PREFIX = "# akm:disabled ";
|
|
37
38
|
const BLOCK_RE = /^# akm:task ([\w.@:_-]+) BEGIN$/;
|
|
39
|
+
const BLOCK_END_RE = /^# akm:task ([\w.@:_-]+) END$/;
|
|
38
40
|
export function CRON_BACKEND(options = {}) {
|
|
39
41
|
const exec = options.exec ?? defaultCronExec();
|
|
42
|
+
const fsLike = options.fs ?? nodeFs();
|
|
40
43
|
const logDir = options.logDir ?? getTaskLogDir();
|
|
41
44
|
const akmArgv = options.akmArgv ?? resolveAkmInvocation().argv;
|
|
45
|
+
const envPath = options.envPath === false ? undefined : (options.envPath ?? process.env.PATH);
|
|
46
|
+
const scheduledContext = options.scheduledContext ?? resolveScheduledTaskContext();
|
|
42
47
|
return {
|
|
43
48
|
name: "cron",
|
|
44
49
|
install(task) {
|
|
45
50
|
// Create the log directory before writing the crontab line — cron
|
|
46
51
|
// appends with `>>` and the surrounding shell will fail the entire
|
|
47
52
|
// entry if the parent directory doesn't exist.
|
|
48
|
-
ensureDir(logDir);
|
|
49
|
-
const cronLine = buildCronLine(task, akmArgv, logDir);
|
|
53
|
+
fsLike.ensureDir(logDir);
|
|
54
|
+
const cronLine = buildCronLine(task, akmArgv, logDir, envPath, scheduledContext);
|
|
50
55
|
const existing = readCrontab(exec);
|
|
51
56
|
const block = renderBlock(task.id, cronLine, task.enabled);
|
|
52
57
|
const next = upsertBlock(existing, task.id, block);
|
|
53
|
-
|
|
58
|
+
replaceCrontab(exec, existing, next);
|
|
54
59
|
},
|
|
55
60
|
uninstall(id) {
|
|
56
61
|
const existing = readCrontab(exec);
|
|
57
62
|
const next = removeBlock(existing, id);
|
|
58
|
-
|
|
63
|
+
replaceCrontab(exec, existing, next);
|
|
59
64
|
},
|
|
60
65
|
setEnabled(id, enabled) {
|
|
61
66
|
const existing = readCrontab(exec);
|
|
62
67
|
const next = toggleBlock(existing, id, enabled);
|
|
63
|
-
|
|
68
|
+
replaceCrontab(exec, existing, next);
|
|
64
69
|
},
|
|
65
70
|
list() {
|
|
66
71
|
const existing = readCrontab(exec);
|
|
67
72
|
return listBlocks(existing).map(({ id, body }) => ({ id, signature: normalizeSignature(body) }));
|
|
68
73
|
},
|
|
69
74
|
expectedSignature(task) {
|
|
70
|
-
const cronLine = buildCronLine(task, akmArgv, logDir);
|
|
75
|
+
const cronLine = buildCronLine(task, akmArgv, logDir, envPath, scheduledContext);
|
|
71
76
|
return normalizeSignature(cronBlockBody(cronLine, task.enabled));
|
|
72
77
|
},
|
|
73
78
|
};
|
|
74
79
|
}
|
|
75
80
|
// ── helpers (exported for tests) ────────────────────────────────────────────
|
|
76
|
-
export function buildCronLine(task, akmArgv, logDir) {
|
|
81
|
+
export function buildCronLine(task, akmArgv, logDir, envPath, scheduledContext) {
|
|
77
82
|
const spec = parseSchedule(task.schedule, "cron");
|
|
78
83
|
const cronExpr = translateToCron(spec);
|
|
79
84
|
const logPath = path.join(logDir, `${task.id}.log`);
|
|
80
|
-
const
|
|
81
|
-
|
|
85
|
+
const invocation = buildScheduledTaskInvocation(akmArgv, task.id, scheduledContext);
|
|
86
|
+
const cmd = invocation.argv.map((part) => quoteForCron(part)).join(" ");
|
|
87
|
+
const contextPrefix = Object.entries(invocation.environment)
|
|
88
|
+
.map(([key, value]) => `${key}=${quoteForCron(value)}`)
|
|
89
|
+
.join(" ");
|
|
90
|
+
const pathPrefix = envPath === undefined ? "" : `PATH=${quoteForCron(envPath)} `;
|
|
91
|
+
return `${cronExpr} ${contextPrefix} ${pathPrefix}${cmd} >> ${quoteForCron(logPath)} 2>&1`;
|
|
82
92
|
}
|
|
83
93
|
/** The crontab line as it appears inside a block — commented when disabled. */
|
|
84
94
|
export function cronBlockBody(cronLine, enabled) {
|
|
@@ -93,28 +103,44 @@ export function renderBlock(id, cronLine, enabled) {
|
|
|
93
103
|
* drift signature, and exported for tests.
|
|
94
104
|
*/
|
|
95
105
|
export function listBlocks(existing) {
|
|
106
|
+
return parseBlocks(existing).map(({ id, body }) => ({ id, body }));
|
|
107
|
+
}
|
|
108
|
+
function parseBlocks(existing) {
|
|
96
109
|
const out = [];
|
|
97
110
|
const lines = existing.split(/\r?\n/);
|
|
98
111
|
let currentId = null;
|
|
112
|
+
let start = -1;
|
|
99
113
|
let body = [];
|
|
100
|
-
for (const line of lines) {
|
|
114
|
+
for (const [index, line] of lines.entries()) {
|
|
101
115
|
const begin = line.match(BLOCK_RE);
|
|
102
116
|
if (begin) {
|
|
117
|
+
if (currentId !== null)
|
|
118
|
+
throw malformedBlockError(currentId);
|
|
103
119
|
currentId = begin[1];
|
|
120
|
+
start = index;
|
|
104
121
|
body = [];
|
|
105
122
|
continue;
|
|
106
123
|
}
|
|
107
|
-
|
|
108
|
-
|
|
124
|
+
const end = line.match(BLOCK_END_RE);
|
|
125
|
+
if (end) {
|
|
126
|
+
if (currentId === null || end[1] !== currentId)
|
|
127
|
+
throw malformedBlockError(currentId ?? end[1]);
|
|
128
|
+
out.push({ id: currentId, body: body.join("\n"), start, end: index });
|
|
109
129
|
currentId = null;
|
|
130
|
+
start = -1;
|
|
110
131
|
body = [];
|
|
111
132
|
continue;
|
|
112
133
|
}
|
|
113
134
|
if (currentId !== null)
|
|
114
135
|
body.push(line);
|
|
115
136
|
}
|
|
137
|
+
if (currentId !== null)
|
|
138
|
+
throw malformedBlockError(currentId);
|
|
116
139
|
return out;
|
|
117
140
|
}
|
|
141
|
+
function malformedBlockError(id) {
|
|
142
|
+
return new ConfigError(`Crontab contains a malformed akm task block for "${id}"; refusing to modify it.`, "INVALID_CONFIG_FILE");
|
|
143
|
+
}
|
|
118
144
|
/** Collapse incidental whitespace so signature comparison ignores it. */
|
|
119
145
|
function normalizeSignature(body) {
|
|
120
146
|
return body
|
|
@@ -131,27 +157,17 @@ export function upsertBlock(existing, id, block) {
|
|
|
131
157
|
}
|
|
132
158
|
export function removeBlock(existing, id) {
|
|
133
159
|
const lines = existing.split(/\r?\n/);
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
inBlock = true;
|
|
139
|
-
continue;
|
|
140
|
-
}
|
|
141
|
-
if (inBlock && line === END(id)) {
|
|
142
|
-
inBlock = false;
|
|
143
|
-
continue;
|
|
144
|
-
}
|
|
145
|
-
if (inBlock)
|
|
146
|
-
continue;
|
|
147
|
-
out.push(line);
|
|
148
|
-
}
|
|
160
|
+
const blocks = parseBlocks(existing).filter((block) => block.id === id);
|
|
161
|
+
if (blocks.length === 0)
|
|
162
|
+
return existing;
|
|
163
|
+
const out = lines.filter((_, index) => !blocks.some((block) => index >= block.start && index <= block.end));
|
|
149
164
|
// Collapse trailing blank lines.
|
|
150
165
|
while (out.length > 0 && out[out.length - 1] === "")
|
|
151
166
|
out.pop();
|
|
152
167
|
return out.join("\n");
|
|
153
168
|
}
|
|
154
169
|
export function toggleBlock(existing, id, enabled) {
|
|
170
|
+
parseBlocks(existing);
|
|
155
171
|
const lines = existing.split(/\r?\n/);
|
|
156
172
|
const out = [];
|
|
157
173
|
let inBlock = false;
|
|
@@ -184,12 +200,23 @@ export function toggleBlock(existing, id, enabled) {
|
|
|
184
200
|
return out.join("\n");
|
|
185
201
|
}
|
|
186
202
|
function quoteForCron(part) {
|
|
203
|
+
assertCronValue(part);
|
|
187
204
|
// crontab passes the rest of the line to /bin/sh -c, so quote anything that
|
|
188
205
|
// isn't a plain shell-safe token. Single-quote and escape embedded single
|
|
189
|
-
// quotes via the standard shell idiom: `'foo'\''bar'`.
|
|
206
|
+
// quotes via the standard shell idiom: `'foo'\''bar'`. Cron interprets `%`
|
|
207
|
+
// before the shell, even inside quotes, so close the quote around its escape.
|
|
190
208
|
if (/^[A-Za-z0-9_\-./@:%=+,]+$/.test(part))
|
|
191
|
-
return part;
|
|
192
|
-
return `'${part.replace(/'/g, `'\\''`)}'`;
|
|
209
|
+
return part.replaceAll("%", "\\%");
|
|
210
|
+
return `'${part.replace(/'/g, `'\\''`).replace(/%/g, `'\\%'`)}'`;
|
|
211
|
+
}
|
|
212
|
+
function assertCronValue(value) {
|
|
213
|
+
for (const char of value) {
|
|
214
|
+
const code = char.codePointAt(0) ?? 0;
|
|
215
|
+
if (code < 0x20 || (code >= 0x7f && code <= 0x9f)) {
|
|
216
|
+
throw new ConfigError("Cron values must not contain control characters.", "INVALID_CONFIG_FILE");
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return value;
|
|
193
220
|
}
|
|
194
221
|
function readCrontab(exec) {
|
|
195
222
|
const result = exec.read();
|
|
@@ -209,13 +236,19 @@ function writeCrontab(exec, content) {
|
|
|
209
236
|
throw new ConfigError(`crontab - failed (exit ${result.status}): ${result.stderr || result.stdout || "no output"}.`, "INVALID_CONFIG_FILE", "Ensure the `crontab` binary is on PATH and your shell can write the user crontab.");
|
|
210
237
|
}
|
|
211
238
|
}
|
|
212
|
-
function
|
|
239
|
+
function replaceCrontab(exec, existing, next) {
|
|
213
240
|
try {
|
|
214
|
-
|
|
241
|
+
writeCrontab(exec, next);
|
|
215
242
|
}
|
|
216
|
-
catch {
|
|
217
|
-
|
|
218
|
-
|
|
243
|
+
catch (err) {
|
|
244
|
+
try {
|
|
245
|
+
writeCrontab(exec, existing);
|
|
246
|
+
}
|
|
247
|
+
catch (rollbackError) {
|
|
248
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
249
|
+
throw new AggregateError([err, rollbackError], `${message}; restoring the prior crontab also failed.`);
|
|
250
|
+
}
|
|
251
|
+
throw err;
|
|
219
252
|
}
|
|
220
253
|
}
|
|
221
254
|
function defaultCronExec() {
|
|
@@ -40,13 +40,65 @@ export function nodeFs() {
|
|
|
40
40
|
*/
|
|
41
41
|
export function spawnCommand(args) {
|
|
42
42
|
const [bin, ...rest] = args;
|
|
43
|
-
const r = spawnSync(bin, rest
|
|
43
|
+
const r = spawnSync(bin, rest);
|
|
44
44
|
return {
|
|
45
45
|
status: r.status ?? 1,
|
|
46
|
-
stdout: r.stdout
|
|
47
|
-
stderr: r.stderr
|
|
46
|
+
stdout: decodeCommandOutput(r.stdout),
|
|
47
|
+
stderr: decodeCommandOutput(r.stderr),
|
|
48
48
|
};
|
|
49
49
|
}
|
|
50
|
+
/** Decode native command output, including the UTF-16 XML emitted by schtasks. */
|
|
51
|
+
export function decodeCommandOutput(output) {
|
|
52
|
+
if (output === null || output === undefined)
|
|
53
|
+
return "";
|
|
54
|
+
if (typeof output === "string")
|
|
55
|
+
return output.replace(/^\uFEFF/, "");
|
|
56
|
+
if (output.length === 0)
|
|
57
|
+
return "";
|
|
58
|
+
if (output[0] === 0xef && output[1] === 0xbb && output[2] === 0xbf) {
|
|
59
|
+
return output.toString("utf8", 3);
|
|
60
|
+
}
|
|
61
|
+
if (output[0] === 0xff && output[1] === 0xfe) {
|
|
62
|
+
return output.subarray(2).toString("utf16le");
|
|
63
|
+
}
|
|
64
|
+
if (output[0] === 0xfe && output[1] === 0xff) {
|
|
65
|
+
return decodeUtf16Be(output.subarray(2));
|
|
66
|
+
}
|
|
67
|
+
const pairs = Math.floor(output.length / 2);
|
|
68
|
+
if (pairs > 1) {
|
|
69
|
+
let evenNuls = 0;
|
|
70
|
+
let oddNuls = 0;
|
|
71
|
+
for (let i = 0; i < pairs * 2; i += 2) {
|
|
72
|
+
if (output[i] === 0)
|
|
73
|
+
evenNuls += 1;
|
|
74
|
+
if (output[i + 1] === 0)
|
|
75
|
+
oddNuls += 1;
|
|
76
|
+
}
|
|
77
|
+
if (oddNuls / pairs > 0.6)
|
|
78
|
+
return output.toString("utf16le");
|
|
79
|
+
if (evenNuls / pairs > 0.6)
|
|
80
|
+
return decodeUtf16Be(output);
|
|
81
|
+
}
|
|
82
|
+
return output.toString("utf8");
|
|
83
|
+
}
|
|
84
|
+
/** Return XML with the declaration required by Task Scheduler's UTF-16 input. */
|
|
85
|
+
export function normalizeXmlForUtf16File(xml) {
|
|
86
|
+
const source = xml.replace(/^\uFEFF/, "");
|
|
87
|
+
const declaration = '<?xml version="1.0" encoding="UTF-16"?>';
|
|
88
|
+
if (/^<\?xml\b[^?]*\?>/i.test(source)) {
|
|
89
|
+
return source.replace(/^<\?xml\b[^?]*\?>/i, declaration);
|
|
90
|
+
}
|
|
91
|
+
return `${declaration}\n${source}`;
|
|
92
|
+
}
|
|
93
|
+
function decodeUtf16Be(output) {
|
|
94
|
+
const evenLength = output.length - (output.length % 2);
|
|
95
|
+
const swapped = Buffer.allocUnsafe(evenLength);
|
|
96
|
+
for (let i = 0; i < evenLength; i += 2) {
|
|
97
|
+
swapped[i] = output[i + 1];
|
|
98
|
+
swapped[i + 1] = output[i];
|
|
99
|
+
}
|
|
100
|
+
return swapped.toString("utf16le");
|
|
101
|
+
}
|
|
50
102
|
/**
|
|
51
103
|
* Escape a string for safe embedding in an XML attribute or text node.
|
|
52
104
|
*/
|