@anhlp/alp-core 0.0.0-beta.1 → 0.10.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/hook-run.d.ts +1 -1
- package/dist/hook-run.js +9 -3
- package/dist/index.d.ts +0 -5
- package/dist/index.js +0 -10
- package/dist/library-edit.js +1 -11
- package/dist/library.js +2 -4
- package/dist/permissions.js +6 -9
- package/dist/providers.d.ts +0 -12
- package/dist/providers.js +0 -18
- package/dist/task-rows.d.ts +0 -1
- package/dist/task-rows.js +0 -1
- package/dist/tasks.d.ts +1 -2
- package/dist/tasks.js +10 -25
- package/dist/trust.js +2 -3
- package/dist/user-settings.js +5 -6
- package/dist/verify.js +15 -3
- package/package.json +6 -53
- package/src/hook-run.js +4 -4
- package/src/index.js +0 -5
- package/src/library-edit.js +1 -14
- package/src/library.js +2 -4
- package/src/permissions.js +6 -9
- package/src/providers.js +0 -28
- package/src/task-rows.js +1 -1
- package/src/tasks.d.ts +1 -2
- package/src/tasks.js +9 -28
- package/src/trust.js +2 -3
- package/src/user-settings.js +5 -6
- package/src/verify.js +6 -3
- package/LICENSE +0 -202
- package/NOTICE +0 -7
- package/dist/atomic.d.ts +0 -2
- package/dist/atomic.js +0 -28
- package/dist/backup.d.ts +0 -27
- package/dist/backup.js +0 -582
- package/dist/log.d.ts +0 -37
- package/dist/log.js +0 -156
- package/dist/migrations.d.ts +0 -9
- package/dist/migrations.js +0 -95
- package/dist/safe-path.d.ts +0 -2
- package/dist/safe-path.js +0 -24
- package/dist/shell.d.ts +0 -16
- package/dist/shell.js +0 -22
- package/dist/state-schema.d.ts +0 -9
- package/dist/state-schema.js +0 -54
- package/src/atomic.d.ts +0 -2
- package/src/atomic.js +0 -28
- package/src/backup.d.ts +0 -27
- package/src/backup.js +0 -570
- package/src/log.d.ts +0 -37
- package/src/log.js +0 -177
- package/src/migrations.d.ts +0 -9
- package/src/migrations.js +0 -111
- package/src/safe-path.js +0 -20
- package/src/shell.js +0 -28
- package/src/state-schema.d.ts +0 -9
- package/src/state-schema.js +0 -60
package/src/log.js
DELETED
|
@@ -1,177 +0,0 @@
|
|
|
1
|
-
import { open, stat } from 'node:fs/promises';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Structured, redacted logging for ALP processes. One JSON object per line, written to stderr
|
|
5
|
-
* unless a sink is given: stdout belongs to command output, `--json` and the ACP stream, so a
|
|
6
|
-
* log line never lands there. A record has `time`, `level`, `component`, an optional error
|
|
7
|
-
* `code`, `msg`, correlation ids (`sessionId`, `rootId`, `assignmentId`, `clientMessageId`,
|
|
8
|
-
* `requestId`, `agent`) and a few extra fields. Logging never throws.
|
|
9
|
-
*
|
|
10
|
-
* Nothing sensitive is logged by default: keys that carry prompts, environments or credentials
|
|
11
|
-
* are replaced, secret-looking values and the values of secret-named environment variables are
|
|
12
|
-
* scrubbed from every string, and long strings are cut.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
export const LEVELS = Object.freeze({ debug: 10, info: 20, warn: 30, error: 40 });
|
|
16
|
-
export const REDACTED = '[redacted]';
|
|
17
|
-
export const OMITTED = '[omitted]';
|
|
18
|
-
const MAX_STRING = 2000;
|
|
19
|
-
const MAX_DEPTH = 5;
|
|
20
|
-
const MAX_KEYS = 50;
|
|
21
|
-
const MAX_ITEMS = 20;
|
|
22
|
-
const STACK_LINES = 8;
|
|
23
|
-
|
|
24
|
-
/** Fields that hold a credential: the value is replaced. */
|
|
25
|
-
const SECRET_KEY = /pass(word|wd)?|secret|token|api[-_]?key|authorization|credential|cookie|private[-_]?key|bearer|signature/i;
|
|
26
|
-
/** Fields that hold user content or the process environment: never logged, not even scrubbed. */
|
|
27
|
-
const OMITTED_KEY = /^(env|environment|prompt|prompts|instructions|content|text|input|headers|body|args|argv)$/i;
|
|
28
|
-
/** Environment variable names whose values are secrets, to find them inside any text. */
|
|
29
|
-
const SECRET_NAME = /KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH|COOKIE|PRIVATE/i;
|
|
30
|
-
|
|
31
|
-
const PATTERNS = [
|
|
32
|
-
[/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi, `$1 ${REDACTED}`],
|
|
33
|
-
[/\b(sk|rk|pk)-[A-Za-z0-9_-]{16,}/g, REDACTED],
|
|
34
|
-
[/\bgh[pousr]_[A-Za-z0-9]{20,}/g, REDACTED],
|
|
35
|
-
[/\bgithub_pat_[A-Za-z0-9_]{20,}/g, REDACTED],
|
|
36
|
-
[/\bxox[abprs]-[A-Za-z0-9-]{10,}/g, REDACTED],
|
|
37
|
-
[/\bAKIA[0-9A-Z]{16}\b/g, REDACTED],
|
|
38
|
-
[/\bAIza[0-9A-Za-z_-]{30,}/g, REDACTED],
|
|
39
|
-
[/\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}/g, REDACTED],
|
|
40
|
-
[/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(-----END [A-Z ]*PRIVATE KEY-----|$)/g, REDACTED],
|
|
41
|
-
[/(\b[a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+:[^\s/@]+@/gi, `$1${REDACTED}@`],
|
|
42
|
-
// NAME=value and NAME: value for names that look like secrets; also JSON "token": "value".
|
|
43
|
-
[/(\b[\w.-]*(?:key|token|secret|passw(?:or)?d|credential|authorization|cookie)[\w.-]*["']?\s*[=:]\s*)(?:"[^"]{4,}"|'[^']{4,}'|[^\s,;&"']{8,})/gi, `$1${REDACTED}`],
|
|
44
|
-
// --token value, --api-key value
|
|
45
|
-
[/(--[\w-]*(?:key|token|secret|passw(?:or)?d|credential)[\w-]*\s+)[^\s-][^\s]*/gi, `$1${REDACTED}`],
|
|
46
|
-
];
|
|
47
|
-
|
|
48
|
-
/** Values of secret-named variables in `env`, long enough that replacing them is not noise. */
|
|
49
|
-
function secretValues(env) {
|
|
50
|
-
const values = new Set();
|
|
51
|
-
for (const [name, value] of Object.entries(env ?? {})) if (typeof value === 'string' && value.length >= 8 && SECRET_NAME.test(name)) values.add(value);
|
|
52
|
-
return [...values].sort((a, b) => b.length - a.length);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/** `text` without anything that looks like a credential, cut to `max` characters. */
|
|
56
|
-
export function scrubText(text, { env = process.env, max = MAX_STRING } = {}) {
|
|
57
|
-
let result = String(text);
|
|
58
|
-
for (const value of secretValues(env)) result = result.split(value).join(REDACTED);
|
|
59
|
-
for (const [pattern, replacement] of PATTERNS) result = result.replace(pattern, replacement);
|
|
60
|
-
return result.length > max ? `${result.slice(0, max)}... (${result.length - max} more characters)` : result;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** An error as a plain, scrubbed object: name, code, message and the top of its stack. */
|
|
64
|
-
export function describeError(error, options = {}) {
|
|
65
|
-
if (!(error instanceof Error)) return { message: scrubText(error, options) };
|
|
66
|
-
const stack = typeof error.stack === 'string' ? error.stack.split('\n').slice(0, STACK_LINES).join('\n') : undefined;
|
|
67
|
-
return {
|
|
68
|
-
name: error.name,
|
|
69
|
-
...(typeof error.code === 'string' || typeof error.code === 'number' ? { code: error.code } : {}),
|
|
70
|
-
message: scrubText(error.message, options),
|
|
71
|
-
...(stack ? { stack: scrubText(stack, options) } : {}),
|
|
72
|
-
...(error.cause !== undefined ? { cause: describeError(error.cause, options) } : {}),
|
|
73
|
-
};
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** A copy of `value` that is safe to write to a log: see the module comment. */
|
|
77
|
-
export function redact(value, options = {}, depth = 0, seen = new WeakSet()) {
|
|
78
|
-
if (value === null || value === undefined || typeof value === 'boolean' || typeof value === 'number') return value;
|
|
79
|
-
if (typeof value === 'string') return scrubText(value, options);
|
|
80
|
-
if (typeof value === 'bigint') return String(value);
|
|
81
|
-
if (typeof value !== 'object') return undefined;
|
|
82
|
-
if (value instanceof Error) return describeError(value, options);
|
|
83
|
-
if (seen.has(value)) return '[circular]';
|
|
84
|
-
if (depth >= MAX_DEPTH) return '[too deep]';
|
|
85
|
-
seen.add(value);
|
|
86
|
-
if (Array.isArray(value)) {
|
|
87
|
-
const items = value.slice(0, MAX_ITEMS).map(item => redact(item, options, depth + 1, seen));
|
|
88
|
-
return value.length > MAX_ITEMS ? [...items, `... ${value.length - MAX_ITEMS} more`] : items;
|
|
89
|
-
}
|
|
90
|
-
const result = {};
|
|
91
|
-
for (const [key, item] of Object.entries(value).slice(0, MAX_KEYS)) {
|
|
92
|
-
result[key] = OMITTED_KEY.test(key) ? OMITTED : SECRET_KEY.test(key) ? REDACTED : redact(item, options, depth + 1, seen);
|
|
93
|
-
}
|
|
94
|
-
return result;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/** Fields every record may carry to tie its lines to a session, assignment or request. */
|
|
98
|
-
const CORRELATION = ['sessionId', 'rootId', 'assignmentId', 'clientMessageId', 'requestId', 'agent'];
|
|
99
|
-
|
|
100
|
-
const stderrSink = line => { try { process.stderr.write(`${line}\n`); } catch {} };
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* A logger for `component` (`alpd`, `store`, `runtime`, `acp`, ...). `level` defaults to
|
|
104
|
-
* ALP_LOG_LEVEL, then `info`. `context` ids are added to every record. `write` receives one
|
|
105
|
-
* serialized line without its newline.
|
|
106
|
-
*/
|
|
107
|
-
export function createLogger({ component, level, context = {}, write = stderrSink, now = () => new Date(), env = process.env } = {}) {
|
|
108
|
-
const threshold = LEVELS[level ?? env.ALP_LOG_LEVEL] ?? LEVELS.info;
|
|
109
|
-
const options = { env };
|
|
110
|
-
const emit = (name, message, fields = {}) => {
|
|
111
|
-
if (LEVELS[name] < threshold) return;
|
|
112
|
-
try {
|
|
113
|
-
const { code, error, ...rest } = { ...context, ...fields };
|
|
114
|
-
const record = {
|
|
115
|
-
time: now().toISOString(), level: name, component, ...(code ? { code: String(code) } : {}), msg: scrubText(message, options),
|
|
116
|
-
};
|
|
117
|
-
for (const key of CORRELATION) if (rest[key] !== undefined) record[key] = redact(String(rest[key]), options);
|
|
118
|
-
if (error !== undefined) {
|
|
119
|
-
record.error = describeError(error, options);
|
|
120
|
-
// An error's own code names the failure when the caller gave none.
|
|
121
|
-
if (!record.code && record.error.code !== undefined) record.code = String(record.error.code);
|
|
122
|
-
}
|
|
123
|
-
for (const [key, value] of Object.entries(rest)) {
|
|
124
|
-
if (CORRELATION.includes(key)) continue;
|
|
125
|
-
record[key] = OMITTED_KEY.test(key) ? OMITTED : SECRET_KEY.test(key) ? REDACTED : redact(value, options);
|
|
126
|
-
}
|
|
127
|
-
write(JSON.stringify(record));
|
|
128
|
-
} catch {}
|
|
129
|
-
};
|
|
130
|
-
return {
|
|
131
|
-
component,
|
|
132
|
-
debug: (message, fields) => emit('debug', message, fields),
|
|
133
|
-
info: (message, fields) => emit('info', message, fields),
|
|
134
|
-
warn: (message, fields) => emit('warn', message, fields),
|
|
135
|
-
error: (message, fields) => emit('error', message, fields),
|
|
136
|
-
child: (extra = {}, name = component) => createLogger({ component: name, level, context: { ...context, ...extra }, write, now, env }),
|
|
137
|
-
};
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const TAIL_BYTES = 256 * 1024;
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* The records at the end of a log file, oldest first. Lines that are not ALP records (output
|
|
144
|
-
* from before structured logging, a runtime's own stderr) are returned as `{ legacy: text }`
|
|
145
|
-
* with the text scrubbed. A file that is missing gives an empty list.
|
|
146
|
-
*/
|
|
147
|
-
export async function readLogTail(file, { bytes = TAIL_BYTES, env = process.env } = {}) {
|
|
148
|
-
let handle;
|
|
149
|
-
try {
|
|
150
|
-
handle = await open(file, 'r');
|
|
151
|
-
const { size } = await stat(file);
|
|
152
|
-
const start = Math.max(0, size - bytes);
|
|
153
|
-
const buffer = Buffer.alloc(size - start);
|
|
154
|
-
await handle.read(buffer, 0, buffer.length, start);
|
|
155
|
-
const lines = buffer.toString('utf8').split('\n');
|
|
156
|
-
// The first line of a tail may be cut.
|
|
157
|
-
if (start > 0) lines.shift();
|
|
158
|
-
const records = [];
|
|
159
|
-
for (const line of lines) {
|
|
160
|
-
if (!line.trim()) continue;
|
|
161
|
-
try {
|
|
162
|
-
const record = JSON.parse(line);
|
|
163
|
-
if (record && typeof record === 'object' && typeof record.time === 'string' && typeof record.level === 'string' && typeof record.msg === 'string') {
|
|
164
|
-
records.push(redact(record, { env }));
|
|
165
|
-
continue;
|
|
166
|
-
}
|
|
167
|
-
} catch {}
|
|
168
|
-
records.push({ legacy: scrubText(line, { env, max: 500 }) });
|
|
169
|
-
}
|
|
170
|
-
return records;
|
|
171
|
-
} catch (error) {
|
|
172
|
-
if (error?.code === 'ENOENT') return [];
|
|
173
|
-
throw error;
|
|
174
|
-
} finally {
|
|
175
|
-
await handle?.close();
|
|
176
|
-
}
|
|
177
|
-
}
|
package/src/migrations.d.ts
DELETED
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
export type Migration = { from: number; to: number; name: string; run(home: string): Promise<void> };
|
|
2
|
-
export const MIGRATIONS: Migration[];
|
|
3
|
-
export type MigrationResult = { from: number; to: number; steps: Array<{ name: string; from: number; to: number; backup: string; resumed: boolean }> };
|
|
4
|
-
export function migrateState(home: string, options?: {
|
|
5
|
-
by?: string;
|
|
6
|
-
migrations?: Migration[];
|
|
7
|
-
target?: number;
|
|
8
|
-
log?: { info(message: string, fields?: object): void; warn(message: string, fields?: object): void };
|
|
9
|
-
}): Promise<MigrationResult>;
|
package/src/migrations.js
DELETED
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
import { readdir, readFile, rm, stat } from 'node:fs/promises';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import { AlpError } from './errors.js';
|
|
4
|
-
import { writeAtomic } from './atomic.js';
|
|
5
|
-
import { STATE_SCHEMA, assertStateSupported, migrationFile, readStateSchema, stateDir, writeStateSchema } from './state-schema.js';
|
|
6
|
-
import { STATE_UNITS, automaticBackup, restoreBackup } from './backup.js';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Versioned migrations of alpd's state (plans/reference/ALPD.md §68). Each step moves the
|
|
10
|
-
* state from one schema to the next and is idempotent: run again over its own partial or
|
|
11
|
-
* complete result it writes the same files. Before a step, the state is saved to a backup
|
|
12
|
-
* and `state/migration.json` names it. Policy:
|
|
13
|
-
*
|
|
14
|
-
* - interrupted (a crash, a kill, a power cut): the next start finds the journal and runs
|
|
15
|
-
* the step again over what it left, with the same backup;
|
|
16
|
-
* - failed (the step threw): the state is restored from that backup, the journal removed,
|
|
17
|
-
* and the start fails with STATE_MIGRATION_FAILED; the backup stays.
|
|
18
|
-
*
|
|
19
|
-
* The schema marker is written only after a step completed, and the journal removed last.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Schema 1 → 2: every timeline line gets the cursor the store already gives it when reading
|
|
24
|
-
* (a line without a larger cursor than the line before is numbered one past it). Lines that
|
|
25
|
-
* do not parse are kept as they are; the store skips them, as before.
|
|
26
|
-
*/
|
|
27
|
-
async function timelineCursors(home) {
|
|
28
|
-
const directory = path.join(stateDir(home), 'timeline');
|
|
29
|
-
const names = await readdir(directory).catch(error => { if (error?.code === 'ENOENT') return []; throw error; });
|
|
30
|
-
for (const name of names.filter(name => name.endsWith('.jsonl'))) {
|
|
31
|
-
const file = path.join(directory, name);
|
|
32
|
-
const lines = [];
|
|
33
|
-
let cursor = 0;
|
|
34
|
-
let changed = false;
|
|
35
|
-
for (const line of (await readFile(file, 'utf8')).split('\n')) {
|
|
36
|
-
if (!line) continue;
|
|
37
|
-
let envelope;
|
|
38
|
-
try { envelope = JSON.parse(line); } catch { lines.push(line); continue; }
|
|
39
|
-
cursor = typeof envelope?.cursor === 'number' && envelope.cursor > cursor ? envelope.cursor : cursor + 1;
|
|
40
|
-
if (envelope?.cursor === cursor) { lines.push(line); continue; }
|
|
41
|
-
lines.push(JSON.stringify({ ...envelope, cursor }));
|
|
42
|
-
changed = true;
|
|
43
|
-
}
|
|
44
|
-
if (changed) await writeAtomic(file, lines.map(line => `${line}\n`).join(''));
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** @type {Array<{ from: number, to: number, name: string, run(home: string): Promise<void> }>} */
|
|
49
|
-
export const MIGRATIONS = [
|
|
50
|
-
{ from: 1, to: 2, name: 'timeline-cursors', run: timelineCursors },
|
|
51
|
-
];
|
|
52
|
-
|
|
53
|
-
async function readJournal(home) {
|
|
54
|
-
try { return JSON.parse(await readFile(migrationFile(home), 'utf8')); }
|
|
55
|
-
catch (error) {
|
|
56
|
-
if (error?.code === 'ENOENT') return undefined;
|
|
57
|
-
// A journal cut short says only that a migration was under way: start the step over.
|
|
58
|
-
return {};
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const exists = file => stat(file).then(() => true, () => false);
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Brings the state to `target` (the current schema), or refuses a newer one without touching it.
|
|
66
|
-
* Returns the steps it ran; `resumed` marks a step an interruption had left unfinished.
|
|
67
|
-
* @param {string} home
|
|
68
|
-
* @param {{ by?: string, migrations?: typeof MIGRATIONS, target?: number, log?: { info(message: string, fields?: object): void, warn(message: string, fields?: object): void } }} [options]
|
|
69
|
-
*/
|
|
70
|
-
export async function migrateState(home, { by = 'alp', migrations = MIGRATIONS, target = STATE_SCHEMA, log } = {}) {
|
|
71
|
-
const info = assertStateSupported(await readStateSchema(home), home);
|
|
72
|
-
let journal = await readJournal(home);
|
|
73
|
-
if (journal && Number.isSafeInteger(journal.to) && journal.to <= info.schema) {
|
|
74
|
-
// The step completed and only the journal's removal was cut off.
|
|
75
|
-
await rm(migrationFile(home), { force: true });
|
|
76
|
-
journal = undefined;
|
|
77
|
-
}
|
|
78
|
-
if (info.fresh && !journal) {
|
|
79
|
-
await writeStateSchema(home, target, by);
|
|
80
|
-
return { from: target, to: target, steps: [] };
|
|
81
|
-
}
|
|
82
|
-
let schema = info.schema;
|
|
83
|
-
const steps = [];
|
|
84
|
-
while (schema < target) {
|
|
85
|
-
const step = migrations.find(candidate => candidate.from === schema);
|
|
86
|
-
if (!step) throw new AlpError('STATE_MIGRATION_MISSING', `ALP has no migration from state schema ${schema}; the state was not changed`);
|
|
87
|
-
const resumed = journal?.to === step.to && typeof journal.backup === 'string' && await exists(journal.backup);
|
|
88
|
-
const backup = resumed ? journal.backup : await automaticBackup(home, `pre-migrate-v${step.from}-v${step.to}`, { units: STATE_UNITS, by });
|
|
89
|
-
if (!resumed) await writeAtomic(migrationFile(home), JSON.stringify({ from: step.from, to: step.to, name: step.name, backup, by, startedAt: new Date().toISOString() }) + '\n');
|
|
90
|
-
log?.info(`${resumed ? 'continuing' : 'running'} state migration ${step.name} (schema ${step.from} → ${step.to}); backup ${backup}`, { code: 'ALP_STATE_MIGRATING', from: step.from, to: step.to, migration: step.name, resumed });
|
|
91
|
-
try {
|
|
92
|
-
await step.run(home);
|
|
93
|
-
} catch (error) {
|
|
94
|
-
try {
|
|
95
|
-
await restoreBackup({ home, file: backup, projects: false, safety: false, clearLive: false, by });
|
|
96
|
-
} catch (rollback) {
|
|
97
|
-
// The journal stays: the next start runs the step again over what is there.
|
|
98
|
-
throw new AlpError('STATE_MIGRATION_ROLLBACK_FAILED', `state migration ${step.name} failed (${error?.message ?? error}) and putting the state back from ${backup} failed too (${rollback?.message ?? rollback}); the next start tries the migration again. See docs/alp/data-recovery.md`, { cause: error });
|
|
99
|
-
}
|
|
100
|
-
await rm(migrationFile(home), { force: true });
|
|
101
|
-
throw new AlpError('STATE_MIGRATION_FAILED', `state migration ${step.name} (schema ${step.from} → ${step.to}) failed: ${error?.message ?? error}; the state was put back from ${backup} and is unchanged. See docs/alp/data-recovery.md`, { cause: error });
|
|
102
|
-
}
|
|
103
|
-
await writeStateSchema(home, step.to, by);
|
|
104
|
-
await rm(migrationFile(home), { force: true });
|
|
105
|
-
steps.push({ name: step.name, from: step.from, to: step.to, backup, resumed });
|
|
106
|
-
schema = step.to;
|
|
107
|
-
journal = undefined;
|
|
108
|
-
}
|
|
109
|
-
if (!info.marked && !steps.length) await writeStateSchema(home, schema, by);
|
|
110
|
-
return { from: info.schema, to: schema, steps };
|
|
111
|
-
}
|
package/src/safe-path.js
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
import { lstat } from 'node:fs/promises';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import { AlpError } from './errors.js';
|
|
4
|
-
|
|
5
|
-
/** Preflight static repository paths. Does not isolate concurrent same-user processes. */
|
|
6
|
-
export async function assertNoSymlinks(directory, relative) {
|
|
7
|
-
const root = path.resolve(directory);
|
|
8
|
-
const target = path.resolve(root, relative);
|
|
9
|
-
const tail = path.relative(root, target);
|
|
10
|
-
if (tail === '..' || tail.startsWith(`..${path.sep}`) || path.isAbsolute(tail)) {
|
|
11
|
-
throw new AlpError('UNSAFE_PATH', 'Path escapes its scope');
|
|
12
|
-
}
|
|
13
|
-
let current = root;
|
|
14
|
-
for (const part of ['', ...tail.split(path.sep).filter(Boolean)]) {
|
|
15
|
-
if (part) current = path.join(current, part);
|
|
16
|
-
const info = await lstat(current).catch(error => { if (error.code !== 'ENOENT') throw error; });
|
|
17
|
-
if (!info) return;
|
|
18
|
-
if (info.isSymbolicLink()) throw new AlpError('UNSAFE_PATH', `Refusing symlink in scoped path: ${current}`);
|
|
19
|
-
}
|
|
20
|
-
}
|
package/src/shell.js
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import { execFile, spawn } from 'node:child_process';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Starts `command` through the platform's shell: /bin/sh -c in its own process group, or on
|
|
5
|
-
* Windows cmd.exe /d /s /c with the line quoted whole (cmd strips only the outermost quotes,
|
|
6
|
-
* and Node would otherwise escape the quotes inside it).
|
|
7
|
-
* @param {string} command
|
|
8
|
-
* @param {import('node:child_process').SpawnOptions} options
|
|
9
|
-
*/
|
|
10
|
-
export function spawnShell(command, options) {
|
|
11
|
-
if (process.platform !== 'win32') return spawn('/bin/sh', ['-c', command], { ...options, detached: true });
|
|
12
|
-
return spawn(process.env.ComSpec ?? 'cmd.exe', ['/d', '/s', '/c', `"${command}"`], { ...options, windowsHide: true, windowsVerbatimArguments: true });
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Signals a command `spawnShell` started and everything it started. Windows has no signals and
|
|
17
|
-
* cmd.exe's children outlive it, so there the whole tree is ended at once by the first signal
|
|
18
|
-
* and no cleanup runs.
|
|
19
|
-
* @param {import('node:child_process').ChildProcess} child
|
|
20
|
-
* @param {NodeJS.Signals} name
|
|
21
|
-
*/
|
|
22
|
-
export function signalShell(child, name) {
|
|
23
|
-
if (process.platform === 'win32') {
|
|
24
|
-
if (name === 'SIGTERM') execFile('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true }, () => {});
|
|
25
|
-
return;
|
|
26
|
-
}
|
|
27
|
-
try { process.kill(-child.pid, name); } catch { child.kill(name); }
|
|
28
|
-
}
|
package/src/state-schema.d.ts
DELETED
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
export const STATE_SCHEMA: number;
|
|
2
|
-
export const OLDEST_STATE_SCHEMA: number;
|
|
3
|
-
export function stateDir(home: string): string;
|
|
4
|
-
export function schemaFile(home: string): string;
|
|
5
|
-
export function migrationFile(home: string): string;
|
|
6
|
-
export type StateSchemaInfo = { schema: number; fresh: boolean; marked: boolean; by?: string };
|
|
7
|
-
export function readStateSchema(home: string): Promise<StateSchemaInfo>;
|
|
8
|
-
export function assertStateSupported(info: StateSchemaInfo, home: string): StateSchemaInfo;
|
|
9
|
-
export function writeStateSchema(home: string, schema: number, by?: string): Promise<void>;
|
package/src/state-schema.js
DELETED
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
import { readdir, readFile, mkdir } from 'node:fs/promises';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import { AlpError } from './errors.js';
|
|
4
|
-
import { writeAtomic } from './atomic.js';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* The version of alpd's durable state in $ALP_HOME/state (plans/reference/ALPD.md §68).
|
|
8
|
-
* `state/schema.json` names it; a state directory without one holds schema 1, the format
|
|
9
|
-
* every ALP before 0.11 wrote. An ALP that finds a newer schema than it knows refuses to
|
|
10
|
-
* open the state and changes nothing in it; an older one is migrated forward at start.
|
|
11
|
-
*
|
|
12
|
-
* 1: session records, receipts and timelines as of ALPD §66; timeline lines may lack a cursor.
|
|
13
|
-
* 2: every timeline line carries its cursor.
|
|
14
|
-
*/
|
|
15
|
-
export const STATE_SCHEMA = 2;
|
|
16
|
-
export const OLDEST_STATE_SCHEMA = 1;
|
|
17
|
-
|
|
18
|
-
export const stateDir = home => path.join(home, 'state');
|
|
19
|
-
export const schemaFile = home => path.join(stateDir(home), 'schema.json');
|
|
20
|
-
export const migrationFile = home => path.join(stateDir(home), 'migration.json');
|
|
21
|
-
|
|
22
|
-
/** Directories whose entries mean the state holds data, so a missing marker means schema 1, not a fresh home. */
|
|
23
|
-
const DATA = ['sessions', 'timeline', 'receipts'];
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* What the state directory says about its schema. `fresh` when it holds no data and no marker.
|
|
27
|
-
* An unreadable marker is an error: ALP does not guess which format the data is in.
|
|
28
|
-
* @param {string} home
|
|
29
|
-
* @returns {Promise<{ schema: number, fresh: boolean, marked: boolean, by?: string }>}
|
|
30
|
-
*/
|
|
31
|
-
export async function readStateSchema(home) {
|
|
32
|
-
let text;
|
|
33
|
-
try { text = await readFile(schemaFile(home), 'utf8'); }
|
|
34
|
-
catch (error) {
|
|
35
|
-
if (error?.code !== 'ENOENT') throw error;
|
|
36
|
-
for (const name of DATA) {
|
|
37
|
-
const entries = await readdir(path.join(stateDir(home), name)).catch(error => { if (error?.code === 'ENOENT') return []; throw error; });
|
|
38
|
-
if (entries.some(entry => !entry.endsWith('.tmp'))) return { schema: 1, fresh: false, marked: false };
|
|
39
|
-
}
|
|
40
|
-
return { schema: STATE_SCHEMA, fresh: true, marked: false };
|
|
41
|
-
}
|
|
42
|
-
let marker;
|
|
43
|
-
try { marker = JSON.parse(text); } catch {}
|
|
44
|
-
if (!marker || !Number.isSafeInteger(marker.schema) || marker.schema < 1) {
|
|
45
|
-
throw new AlpError('STATE_SCHEMA_UNREADABLE', `${schemaFile(home)} does not name a state schema; ALP does not open state whose format it cannot tell. Restore a backup, or put back the file`);
|
|
46
|
-
}
|
|
47
|
-
return { schema: marker.schema, fresh: false, marked: true, ...(typeof marker.by === 'string' ? { by: marker.by } : {}) };
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** Refuses state written in a schema this ALP does not know, before anything touches it. */
|
|
51
|
-
export function assertStateSupported(info, home) {
|
|
52
|
-
if (info.schema <= STATE_SCHEMA) return info;
|
|
53
|
-
throw new AlpError('STATE_SCHEMA_TOO_NEW', `${stateDir(home)} holds state schema ${info.schema}${info.by ? `, written by ${info.by}` : ''}; this ALP knows schema ${STATE_SCHEMA} at most and leaves it untouched. Run that ALP version or a newer one, or replace it with a backup made for this version: alp restore FILE --replace-newer (docs/alp/data-recovery.md)`);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** Records the schema the state is now in; written atomically. */
|
|
57
|
-
export async function writeStateSchema(home, schema, by) {
|
|
58
|
-
await mkdir(stateDir(home), { recursive: true, mode: 0o700 });
|
|
59
|
-
await writeAtomic(schemaFile(home), JSON.stringify({ schema, by, at: new Date().toISOString() }) + '\n');
|
|
60
|
-
}
|