lattris 0.1.4 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/build.json +4 -1
- package/dist/datasets/capture.js +118 -0
- package/dist/datasets/page.js +64 -0
- package/dist/datasets/pieces.js +78 -0
- package/dist/dispatch-form.js +181 -0
- package/dist/dispatch-words.js +137 -0
- package/dist/dispatch.js +67 -0
- package/dist/entry/session.js +219 -0
- package/dist/entry/set.js +130 -0
- package/dist/entry/setup-boot.js +151 -0
- package/dist/entry/setup.js +145 -0
- package/dist/entry/use.js +127 -0
- package/dist/help.js +65 -0
- package/dist/main.js +143 -6887
- package/dist/memory/capture-common.js +67 -0
- package/dist/memory/capture-help.js +186 -0
- package/dist/memory/capture-note.js +190 -0
- package/dist/memory/capture-session.js +166 -0
- package/dist/memory/delete.js +88 -0
- package/dist/memory/erase.js +67 -0
- package/dist/{extract-claude-jsonl.js → memory/extract-claude-jsonl.js} +5 -94
- package/dist/memory/get-help.js +63 -0
- package/dist/memory/get.js +213 -0
- package/dist/memory/history.js +134 -0
- package/dist/memory/recording-update.js +128 -0
- package/dist/memory/recording.js +249 -0
- package/dist/memory/resend.js +82 -0
- package/dist/memory/restore.js +73 -0
- package/dist/memory/save-session-chain.js +59 -0
- package/dist/memory/transcript.js +85 -0
- package/dist/memory/update-help.js +126 -0
- package/dist/memory/update.js +220 -0
- package/dist/memory/views.js +138 -0
- package/dist/nouns/consent.js +29 -0
- package/dist/nouns/contact.js +142 -0
- package/dist/nouns/datasets.js +109 -0
- package/dist/nouns/edges.js +106 -0
- package/dist/nouns/form.js +2 -0
- package/dist/nouns/index.js +34 -0
- package/dist/nouns/invite.js +98 -0
- package/dist/nouns/questions.js +116 -0
- package/dist/nouns/share.js +105 -0
- package/dist/nouns/words.js +76 -0
- package/dist/registry/datasets.js +15 -0
- package/dist/registry/entry.js +51 -0
- package/dist/registry/index.js +17 -0
- package/dist/registry/memory.js +110 -0
- package/dist/render/datasets.js +77 -0
- package/dist/render/door.js +39 -0
- package/dist/render/output.js +19 -0
- package/dist/render/prompt.js +4 -0
- package/dist/render/rows.js +49 -0
- package/dist/render/text.js +21 -0
- package/dist/request/access-token.js +67 -0
- package/dist/request/api-fetch.js +76 -0
- package/dist/request/callback.js +86 -0
- package/dist/request/door.js +74 -0
- package/dist/request/flags.js +38 -0
- package/dist/request/io.js +2 -0
- package/dist/request/paste-and-device.js +99 -0
- package/dist/request/stdin.js +22 -0
- package/dist/request/token.js +66 -0
- package/dist/request/upload.js +6 -0
- package/dist/request/version-check.js +79 -0
- package/dist/store/chains.js +25 -0
- package/dist/store/drafts.js +48 -0
- package/dist/store/environment.js +57 -0
- package/dist/store/exit.js +13 -0
- package/dist/store/io.js +2 -0
- package/dist/store/json-file.js +9 -0
- package/dist/store/session-home.js +189 -0
- package/dist/store/session-lock.js +169 -0
- package/dist/store/session.js +70 -0
- package/dist/store/write-identity.js +153 -0
- package/dist/version.js +16 -0
- package/package.json +4 -5
- package/dist/save-session-chain.js +0 -136
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { basename, dirname, join } from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { EXIT, refusal } from "./exit.js";
|
|
4
|
+
export const stripTrailingSlash = (s) => s.replace(/\/+$/, '');
|
|
5
|
+
export const defaultEnvironmentForEntry = (files, entryUrl) => {
|
|
6
|
+
const entryPath = fileURLToPath(entryUrl);
|
|
7
|
+
const entryDir = dirname(entryPath);
|
|
8
|
+
const isBuiltMain = basename(entryPath) === 'main.js' && basename(entryDir) === 'dist';
|
|
9
|
+
return isBuiltMain && !files.existsSync(join(entryDir, 'installed-check.js')) ? 'prod' : 'local';
|
|
10
|
+
};
|
|
11
|
+
const environmentsPath = (machine) => join(machine.env.XDG_CONFIG_HOME || join(machine.home, '.config'), 'lattris', 'environments.json');
|
|
12
|
+
const BUILTIN_LOCAL = {
|
|
13
|
+
issuer: 'http://localhost:4444',
|
|
14
|
+
api: 'http://localhost:54301',
|
|
15
|
+
web: 'http://localhost:4500',
|
|
16
|
+
clientId: 'lattris-cli'
|
|
17
|
+
};
|
|
18
|
+
export const BUILTIN_PROD = {
|
|
19
|
+
issuer: 'https://api.felix-synthetix.com',
|
|
20
|
+
api: 'https://lattris.com/api/lattris',
|
|
21
|
+
web: 'https://lattris.com',
|
|
22
|
+
clientId: 'lattris-cli'
|
|
23
|
+
};
|
|
24
|
+
const BUILTINS = { local: BUILTIN_LOCAL, prod: BUILTIN_PROD };
|
|
25
|
+
const environmentEntry = (machine, envName) => {
|
|
26
|
+
try {
|
|
27
|
+
const registry = JSON.parse(machine.files.readFileSync(environmentsPath(machine), 'utf8'));
|
|
28
|
+
if (typeof registry === 'object' && registry !== null && Object.hasOwn(registry, envName)) {
|
|
29
|
+
const entry = registry[envName];
|
|
30
|
+
if (entry)
|
|
31
|
+
return entry;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch { }
|
|
35
|
+
return Object.hasOwn(BUILTINS, envName) ? BUILTINS[envName] : undefined;
|
|
36
|
+
};
|
|
37
|
+
export const knownEnvironment = (machine, envName) => environmentEntry(machine, envName) !== undefined;
|
|
38
|
+
const loadEnvironmentEntry = (machine, envName) => environmentEntry(machine, envName) ??
|
|
39
|
+
refusal(`Unknown environment: ${envName}. Known: local, prod (or add it to ${environmentsPath(machine)}).\n`, EXIT.USAGE);
|
|
40
|
+
export const loadConfig = (store) => {
|
|
41
|
+
const entry = loadEnvironmentEntry(store, store.envName);
|
|
42
|
+
if ('exit' in entry)
|
|
43
|
+
return entry;
|
|
44
|
+
const env = store.env;
|
|
45
|
+
return {
|
|
46
|
+
env: store.envName,
|
|
47
|
+
issuer: stripTrailingSlash(env.LATTRIS_ISSUER ?? entry.issuer),
|
|
48
|
+
apiUrl: stripTrailingSlash(env.LATTRIS_API ?? entry.api),
|
|
49
|
+
clientId: env.LATTRIS_CLIENT_ID ?? entry.clientId,
|
|
50
|
+
redirectPort: Number(env.LATTRIS_REDIRECT_PORT ?? '4577'),
|
|
51
|
+
audience: env.LATTRIS_AUDIENCE ?? 'foundations lattris',
|
|
52
|
+
scopes: env.LATTRIS_SCOPES ?? 'openid offline_access lattris:all:all',
|
|
53
|
+
webUrl: stripTrailingSlash(env.LATTRIS_WEB_URL ?? entry.web),
|
|
54
|
+
...(entry.remember_for !== undefined ? { rememberFor: entry.remember_for } : {})
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
//# sourceMappingURL=environment.js.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export const EXIT = {
|
|
2
|
+
OK: 0,
|
|
3
|
+
GENERIC: 1,
|
|
4
|
+
USAGE: 2,
|
|
5
|
+
NOAUTH: 3,
|
|
6
|
+
DOOR: 4
|
|
7
|
+
};
|
|
8
|
+
export const refusal = (message, exit = EXIT.USAGE) => ({
|
|
9
|
+
message,
|
|
10
|
+
exit
|
|
11
|
+
});
|
|
12
|
+
export const isRefusal = (value) => typeof value === 'object' && value !== null && 'exit' in value && 'message' in value;
|
|
13
|
+
//# sourceMappingURL=exit.js.map
|
package/dist/store/io.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { basename, dirname, join, resolve, sep } from 'node:path';
|
|
3
|
+
import { knownEnvironment } from "./environment.js";
|
|
4
|
+
export const stateHome = (machine) => machine.env.XDG_STATE_HOME || join(machine.home, '.local', 'state');
|
|
5
|
+
const stateDir = (machine) => join(stateHome(machine), 'lattris');
|
|
6
|
+
export const machinesRoot = (machine) => {
|
|
7
|
+
const named = machine.env.LATTRIS_MACHINES_ROOT;
|
|
8
|
+
return named
|
|
9
|
+
? resolve(machine.home, named.replace(/^~(?=\/|$)/, machine.home))
|
|
10
|
+
: join(machine.home, 'lattris-machines');
|
|
11
|
+
};
|
|
12
|
+
export const BINDING_FILE = '.lattris';
|
|
13
|
+
const madeHerePath = (machine) => join(stateDir(machine), 'bindings.json');
|
|
14
|
+
const present = (files, path) => {
|
|
15
|
+
try {
|
|
16
|
+
files.lstatSync(path);
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
const realPathOf = (files, path) => {
|
|
24
|
+
try {
|
|
25
|
+
return files.realpathSync(path);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
const parent = dirname(path);
|
|
29
|
+
return parent === path ? path : join(realPathOf(files, parent), basename(path));
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
const bindingKey = (files, bindingFile) => join(realPathOf(files, dirname(bindingFile)), basename(bindingFile));
|
|
33
|
+
const underMachinesRoot = (machine, bindingFile, dir) => {
|
|
34
|
+
const root = realPathOf(machine.files, machinesRoot(machine));
|
|
35
|
+
const under = (path) => path.startsWith(root + sep);
|
|
36
|
+
const from = realPathOf(machine.files, dirname(bindingFile));
|
|
37
|
+
return under(realPathOf(machine.files, dir)) && from !== root && !under(from);
|
|
38
|
+
};
|
|
39
|
+
const digest = (content) => createHash('sha256').update(content).digest('hex');
|
|
40
|
+
const readMadeHere = (machine) => {
|
|
41
|
+
try {
|
|
42
|
+
const made = JSON.parse(machine.files.readFileSync(madeHerePath(machine), 'utf8'));
|
|
43
|
+
return typeof made === 'object' && made !== null ? made : {};
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return {};
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
const wasMadeHere = (machine, bindingFile, content) => {
|
|
50
|
+
const made = readMadeHere(machine);
|
|
51
|
+
const key = bindingKey(machine.files, bindingFile);
|
|
52
|
+
return Object.hasOwn(made, key) && made[key] === digest(content);
|
|
53
|
+
};
|
|
54
|
+
const writeWhole = (machine, file, content, mode) => {
|
|
55
|
+
const draft = `${file}.${randomUUID()}.new`;
|
|
56
|
+
try {
|
|
57
|
+
machine.files.writeFileSync(draft, content, { mode, flag: 'wx' });
|
|
58
|
+
machine.files.renameSync(draft, file);
|
|
59
|
+
}
|
|
60
|
+
catch (e) {
|
|
61
|
+
if (e.code !== 'EEXIST') {
|
|
62
|
+
try {
|
|
63
|
+
machine.files.unlinkSync(draft);
|
|
64
|
+
}
|
|
65
|
+
catch { }
|
|
66
|
+
}
|
|
67
|
+
throw e;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
const saveMadeHere = (machine, made) => {
|
|
71
|
+
machine.files.mkdirSync(stateDir(machine), { recursive: true, mode: 0o700 });
|
|
72
|
+
writeWhole(machine, madeHerePath(machine), `${JSON.stringify(made, null, 2)}\n`, 0o600);
|
|
73
|
+
};
|
|
74
|
+
const readBinding = (machine, bindingFile) => {
|
|
75
|
+
const refused = { trusted: false, bindingFile };
|
|
76
|
+
let content;
|
|
77
|
+
try {
|
|
78
|
+
if (!machine.files.lstatSync(bindingFile).isFile())
|
|
79
|
+
return refused;
|
|
80
|
+
content = machine.files.readFileSync(bindingFile, 'utf8');
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return refused;
|
|
84
|
+
}
|
|
85
|
+
const lines = content
|
|
86
|
+
.trim()
|
|
87
|
+
.split('\n')
|
|
88
|
+
.map((l) => l.trim());
|
|
89
|
+
const pathLine = lines[0];
|
|
90
|
+
const env = lines
|
|
91
|
+
.slice(1)
|
|
92
|
+
.find((l) => l.startsWith('env='))
|
|
93
|
+
?.slice(4)
|
|
94
|
+
.trim();
|
|
95
|
+
if (!pathLine)
|
|
96
|
+
return refused;
|
|
97
|
+
if (env && !knownEnvironment(machine, env))
|
|
98
|
+
return refused;
|
|
99
|
+
const machineDir = resolve(dirname(bindingFile), pathLine);
|
|
100
|
+
if (!underMachinesRoot(machine, bindingFile, machineDir) &&
|
|
101
|
+
!wasMadeHere(machine, bindingFile, content))
|
|
102
|
+
return refused;
|
|
103
|
+
return { trusted: true, bindingFile, machineDir, ...(env ? { env } : {}) };
|
|
104
|
+
};
|
|
105
|
+
export const findBinding = (machine, from = machine.cwd) => {
|
|
106
|
+
let dir = from;
|
|
107
|
+
while (dir.startsWith(machine.home + sep)) {
|
|
108
|
+
const bindingFile = join(dir, BINDING_FILE);
|
|
109
|
+
if (present(machine.files, bindingFile))
|
|
110
|
+
return readBinding(machine, bindingFile);
|
|
111
|
+
const parent = dirname(dir);
|
|
112
|
+
if (parent === dir)
|
|
113
|
+
break;
|
|
114
|
+
dir = parent;
|
|
115
|
+
}
|
|
116
|
+
return undefined;
|
|
117
|
+
};
|
|
118
|
+
export const writeBinding = (machine, dir, target, env) => {
|
|
119
|
+
const bindingFile = join(dir, BINDING_FILE);
|
|
120
|
+
const content = env ? `${target}\nenv=${env}\n` : `${target}\n`;
|
|
121
|
+
writeWhole(machine, bindingFile, content, 0o644);
|
|
122
|
+
if (underMachinesRoot(machine, bindingFile, target))
|
|
123
|
+
return;
|
|
124
|
+
saveMadeHere(machine, {
|
|
125
|
+
...readMadeHere(machine),
|
|
126
|
+
[bindingKey(machine.files, bindingFile)]: digest(content)
|
|
127
|
+
});
|
|
128
|
+
};
|
|
129
|
+
export const clearBinding = (machine, dir) => {
|
|
130
|
+
const bindingFile = join(dir, BINDING_FILE);
|
|
131
|
+
if (!present(machine.files, bindingFile))
|
|
132
|
+
return false;
|
|
133
|
+
const key = bindingKey(machine.files, bindingFile);
|
|
134
|
+
machine.files.rmSync(bindingFile);
|
|
135
|
+
const made = readMadeHere(machine);
|
|
136
|
+
if (Object.hasOwn(made, key)) {
|
|
137
|
+
delete made[key];
|
|
138
|
+
saveMadeHere(machine, made);
|
|
139
|
+
}
|
|
140
|
+
return true;
|
|
141
|
+
};
|
|
142
|
+
export const resolveActorDir = (files, actor, root) => {
|
|
143
|
+
const exact = join(root, actor);
|
|
144
|
+
if (actor.includes('@') || files.existsSync(exact))
|
|
145
|
+
return { dir: exact };
|
|
146
|
+
let entries = [];
|
|
147
|
+
try {
|
|
148
|
+
entries = files.readdirSync(root);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return { dir: exact };
|
|
152
|
+
}
|
|
153
|
+
const matches = entries.filter((e) => e.startsWith(`${actor}@`)).sort();
|
|
154
|
+
if (matches.length === 1)
|
|
155
|
+
return { dir: join(root, matches[0]) };
|
|
156
|
+
if (matches.length > 1)
|
|
157
|
+
return { ambiguous: matches };
|
|
158
|
+
return { dir: exact };
|
|
159
|
+
};
|
|
160
|
+
export const resolveSessionHome = (machine) => {
|
|
161
|
+
if (machine.env.LATTRIS_HOME)
|
|
162
|
+
return { dir: machine.env.LATTRIS_HOME };
|
|
163
|
+
const bound = findBinding(machine);
|
|
164
|
+
if (bound?.trusted)
|
|
165
|
+
return { dir: bound.machineDir, bindingFile: bound.bindingFile, bindingEnv: bound.env };
|
|
166
|
+
return { dir: stateDir(machine), ...(bound ? { untrusted: bound.bindingFile } : {}) };
|
|
167
|
+
};
|
|
168
|
+
export const sessionDir = (store) => {
|
|
169
|
+
const home = resolveSessionHome(store);
|
|
170
|
+
const isDefault = !home.bindingFile && !store.env.LATTRIS_HOME;
|
|
171
|
+
if (isDefault && !store.envExplicit)
|
|
172
|
+
return home.dir;
|
|
173
|
+
const envDir = join(home.dir, store.envName);
|
|
174
|
+
const files = store.files;
|
|
175
|
+
if (store.envName === 'local' &&
|
|
176
|
+
!files.existsSync(join(envDir, 'session.json')) &&
|
|
177
|
+
files.existsSync(join(home.dir, 'session.json'))) {
|
|
178
|
+
files.mkdirSync(envDir, { recursive: true, mode: 0o700 });
|
|
179
|
+
files.renameSync(join(home.dir, 'session.json'), join(envDir, 'session.json'));
|
|
180
|
+
files.chmodSync(join(envDir, 'session.json'), 0o600);
|
|
181
|
+
}
|
|
182
|
+
return envDir;
|
|
183
|
+
};
|
|
184
|
+
export const sessionPath = (store) => join(sessionDir(store), 'session.json');
|
|
185
|
+
export const safeFileName = (name) => {
|
|
186
|
+
const safe = name.replace(/[^A-Za-z0-9._-]/g, '_');
|
|
187
|
+
return safe === '' || safe === '.' || safe === '..' ? undefined : safe;
|
|
188
|
+
};
|
|
189
|
+
//# sourceMappingURL=session-home.js.map
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { sessionPath } from "./session-home.js";
|
|
4
|
+
export const RENEWAL_TIMEOUT_MS = 10_000;
|
|
5
|
+
const STALE_AFTER_MS = 3 * RENEWAL_TIMEOUT_MS;
|
|
6
|
+
export const WAIT_AT_MOST_MS = STALE_AFTER_MS + RENEWAL_TIMEOUT_MS;
|
|
7
|
+
const POLL_EVERY_MS = 50;
|
|
8
|
+
const SAY_WAITING_AFTER_MS = 2_000;
|
|
9
|
+
const NONCE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
10
|
+
export class SessionLockHeld extends Error {
|
|
11
|
+
path;
|
|
12
|
+
constructor(path) {
|
|
13
|
+
super('the session lock is held');
|
|
14
|
+
this.path = path;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
const errorCode = (e) => e?.code;
|
|
18
|
+
const removeQuietly = (store, path) => {
|
|
19
|
+
try {
|
|
20
|
+
store.files.unlinkSync(path);
|
|
21
|
+
}
|
|
22
|
+
catch { }
|
|
23
|
+
};
|
|
24
|
+
const removeLock = (store, path) => {
|
|
25
|
+
try {
|
|
26
|
+
store.files.unlinkSync(path);
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
try {
|
|
31
|
+
store.files.rmdirSync(path);
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
return errorCode(e) === 'ENOENT';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
const parseHolder = (text) => {
|
|
40
|
+
try {
|
|
41
|
+
const held = JSON.parse(text);
|
|
42
|
+
const whole = typeof held?.nonce === 'string' &&
|
|
43
|
+
NONCE.test(held.nonce) &&
|
|
44
|
+
Number.isSafeInteger(held.pid) &&
|
|
45
|
+
Number(held.pid) > 0 &&
|
|
46
|
+
Number.isFinite(held.since);
|
|
47
|
+
return whole ? held : undefined;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
const readHolder = (store, path) => {
|
|
54
|
+
try {
|
|
55
|
+
return parseHolder(store.files.readFileSync(path, 'utf8'));
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
const isUnreadable = (store, path) => {
|
|
62
|
+
try {
|
|
63
|
+
return parseHolder(store.files.readFileSync(path, 'utf8')) === undefined;
|
|
64
|
+
}
|
|
65
|
+
catch (e) {
|
|
66
|
+
return errorCode(e) !== 'ENOENT';
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
const holderNow = (store) => ({
|
|
70
|
+
pid: store.pid,
|
|
71
|
+
since: store.now(),
|
|
72
|
+
nonce: randomUUID()
|
|
73
|
+
});
|
|
74
|
+
const isStale = (store, held) => store.now() - held.since > STALE_AFTER_MS;
|
|
75
|
+
const isAbandoned = (store, held) => isStale(store, held) || held.since - store.now() > STALE_AFTER_MS || !store.alive(held.pid);
|
|
76
|
+
const createWhole = (store, path, holder) => {
|
|
77
|
+
const draft = `${path}.${holder.nonce}.new`;
|
|
78
|
+
try {
|
|
79
|
+
store.files.writeFileSync(draft, JSON.stringify(holder), { mode: 0o600, flag: 'wx' });
|
|
80
|
+
store.files.linkSync(draft, path);
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
catch (e) {
|
|
84
|
+
if (errorCode(e) === 'EEXIST')
|
|
85
|
+
return false;
|
|
86
|
+
throw e;
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
removeQuietly(store, draft);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
const takeOver = (store, path, claim, still) => {
|
|
93
|
+
if (!createWhole(store, claim, holderNow(store))) {
|
|
94
|
+
const taker = readHolder(store, claim);
|
|
95
|
+
if (taker && isAbandoned(store, taker))
|
|
96
|
+
removeQuietly(store, claim);
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
return still() && removeLock(store, path);
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
removeQuietly(store, claim);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
const takeOverAbandoned = (store, path) => {
|
|
107
|
+
const held = readHolder(store, path);
|
|
108
|
+
if (!held) {
|
|
109
|
+
return (isUnreadable(store, path) &&
|
|
110
|
+
takeOver(store, path, `${path}.unreadable`, () => isUnreadable(store, path)));
|
|
111
|
+
}
|
|
112
|
+
return (isAbandoned(store, held) &&
|
|
113
|
+
takeOver(store, path, `${path}.${held.nonce}`, () => readHolder(store, path)?.nonce === held.nonce));
|
|
114
|
+
};
|
|
115
|
+
const acquire = async (store, path, sayWaiting) => {
|
|
116
|
+
const startedAt = store.now();
|
|
117
|
+
let said = false;
|
|
118
|
+
for (;;) {
|
|
119
|
+
const mine = holderNow(store);
|
|
120
|
+
if (createWhole(store, path, mine))
|
|
121
|
+
return mine;
|
|
122
|
+
if (takeOverAbandoned(store, path))
|
|
123
|
+
continue;
|
|
124
|
+
const waited = store.now() - startedAt;
|
|
125
|
+
if (waited >= WAIT_AT_MOST_MS)
|
|
126
|
+
throw new SessionLockHeld(path);
|
|
127
|
+
if (waited > SAY_WAITING_AFTER_MS && !said) {
|
|
128
|
+
said = true;
|
|
129
|
+
sayWaiting();
|
|
130
|
+
}
|
|
131
|
+
await store.wait(POLL_EVERY_MS);
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
const release = (store, path, mine) => {
|
|
135
|
+
if (readHolder(store, path)?.nonce === mine.nonce)
|
|
136
|
+
removeQuietly(store, path);
|
|
137
|
+
};
|
|
138
|
+
const lockPath = (store) => `${sessionPath(store)}.lock`;
|
|
139
|
+
export const withSessionLock = async (store, work, sayWaiting) => {
|
|
140
|
+
const path = lockPath(store);
|
|
141
|
+
const mine = await acquire(store, path, sayWaiting);
|
|
142
|
+
try {
|
|
143
|
+
return await work();
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
release(store, path, mine);
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
export const clearLeftovers = (store) => {
|
|
150
|
+
const session = sessionPath(store);
|
|
151
|
+
const lock = lockPath(store);
|
|
152
|
+
const dir = dirname(session);
|
|
153
|
+
let names;
|
|
154
|
+
try {
|
|
155
|
+
names = store.files.readdirSync(dir);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
for (const name of names) {
|
|
161
|
+
const path = join(dir, name);
|
|
162
|
+
const leftover = path === lock ||
|
|
163
|
+
path.startsWith(`${lock}.`) ||
|
|
164
|
+
(path.startsWith(`${session}.`) && path.endsWith('.new'));
|
|
165
|
+
if (leftover)
|
|
166
|
+
removeLock(store, path);
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
//# sourceMappingURL=session-lock.js.map
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { resolveSessionHome, sessionDir, sessionPath } from "./session-home.js";
|
|
4
|
+
import { clearLeftovers } from "./session-lock.js";
|
|
5
|
+
export const sessionIdentityWarning = (store, asFlag) => {
|
|
6
|
+
const home = resolveSessionHome(store);
|
|
7
|
+
if (!asFlag)
|
|
8
|
+
return undefined;
|
|
9
|
+
const session = loadSession(store);
|
|
10
|
+
if (!session)
|
|
11
|
+
return undefined;
|
|
12
|
+
if (!session.personId || !session.handle) {
|
|
13
|
+
return (`Warning: ${home.dir}/session.json has no recorded identity — ` +
|
|
14
|
+
`run \`lattris login\` to populate it.\n`);
|
|
15
|
+
}
|
|
16
|
+
const dirName = home.dir.split('/').filter(Boolean).at(-1) ?? '';
|
|
17
|
+
if (session.email &&
|
|
18
|
+
dirName.includes('@') &&
|
|
19
|
+
dirName.toLowerCase() !== session.email.toLowerCase()) {
|
|
20
|
+
return (`Warning: ${home.dir}/session.json records ${session.email}, but the directory is ` +
|
|
21
|
+
`keyed to ${dirName} — fix it by logging in as that identity again: \`lattris login\`.\n`);
|
|
22
|
+
}
|
|
23
|
+
return undefined;
|
|
24
|
+
};
|
|
25
|
+
export const notLoggedInWords = (machine) => {
|
|
26
|
+
const home = resolveSessionHome(machine);
|
|
27
|
+
if (home.bindingFile && !machine.files.existsSync(join(home.dir, 'session.json'))) {
|
|
28
|
+
return (`Bound to ${home.dir} (via ${home.bindingFile}) — no session there. ` +
|
|
29
|
+
`Run lattris login from this directory, or lattris use --clear.`);
|
|
30
|
+
}
|
|
31
|
+
return 'Not logged in.';
|
|
32
|
+
};
|
|
33
|
+
export const saveSession = (store, s) => {
|
|
34
|
+
const dir = sessionDir(store);
|
|
35
|
+
store.files.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
36
|
+
store.files.chmodSync(dir, 0o700);
|
|
37
|
+
const file = sessionPath(store);
|
|
38
|
+
const draft = `${file}.${randomUUID()}.new`;
|
|
39
|
+
try {
|
|
40
|
+
store.files.writeFileSync(draft, JSON.stringify(s, null, 2), { mode: 0o600 });
|
|
41
|
+
store.files.chmodSync(draft, 0o600);
|
|
42
|
+
store.files.renameSync(draft, file);
|
|
43
|
+
}
|
|
44
|
+
catch (e) {
|
|
45
|
+
try {
|
|
46
|
+
store.files.unlinkSync(draft);
|
|
47
|
+
}
|
|
48
|
+
catch { }
|
|
49
|
+
throw e;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
export const loadSession = (store) => {
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(store.files.readFileSync(sessionPath(store), 'utf8'));
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
export const clearSession = (store) => {
|
|
61
|
+
try {
|
|
62
|
+
store.files.rmSync(sessionPath(store));
|
|
63
|
+
}
|
|
64
|
+
catch (e) {
|
|
65
|
+
if (e.code !== 'ENOENT')
|
|
66
|
+
throw e;
|
|
67
|
+
}
|
|
68
|
+
clearLeftovers(store);
|
|
69
|
+
};
|
|
70
|
+
//# sourceMappingURL=session.js.map
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { EXIT, refusal } from "./exit.js";
|
|
4
|
+
import { sessionDir } from "./session-home.js";
|
|
5
|
+
const SESSION_ID_MAX = 128;
|
|
6
|
+
const psField = (machine, pid, field) => {
|
|
7
|
+
const out = machine.run('ps', ['-o', `${field}=`, '-p', String(pid)]);
|
|
8
|
+
if (out.status !== 0)
|
|
9
|
+
return undefined;
|
|
10
|
+
const value = out.stdout.trim();
|
|
11
|
+
return value.length > 0 ? value : undefined;
|
|
12
|
+
};
|
|
13
|
+
const terminalKeyFrom = (input) => {
|
|
14
|
+
const tty = input.tty?.trim();
|
|
15
|
+
if (!tty || tty === '??' || tty === '-')
|
|
16
|
+
return undefined;
|
|
17
|
+
const device = tty.replace(/^\/dev\//, '');
|
|
18
|
+
const leader = input.leader();
|
|
19
|
+
if (!leader)
|
|
20
|
+
return undefined;
|
|
21
|
+
const component = (raw) => raw.replace(/[^A-Za-z0-9:]+/g, '-');
|
|
22
|
+
const shell = `leader-${leader.pid}-${component(leader.started)}`;
|
|
23
|
+
const name = `tty-${device.replace(/\//g, '_')}`;
|
|
24
|
+
for (const line of input.who.split('\n')) {
|
|
25
|
+
const columns = line.trim().split(/\s+/);
|
|
26
|
+
if (columns[1] === device) {
|
|
27
|
+
const moment = columns
|
|
28
|
+
.slice(2)
|
|
29
|
+
.filter((c) => !c.startsWith('('))
|
|
30
|
+
.join(' ');
|
|
31
|
+
if (moment.length > 0)
|
|
32
|
+
return `${name}-login-${component(moment)}-${shell}`;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return `${name}-${shell}`;
|
|
36
|
+
};
|
|
37
|
+
const terminalLeader = (machine, device) => {
|
|
38
|
+
let pid = machine.pid;
|
|
39
|
+
for (let hops = 0; hops < 64; hops += 1) {
|
|
40
|
+
const parent = Number(psField(machine, pid, 'ppid'));
|
|
41
|
+
if (!Number.isFinite(parent) || parent <= 1)
|
|
42
|
+
break;
|
|
43
|
+
const parentTty = psField(machine, parent, 'tty');
|
|
44
|
+
if (parentTty === undefined || parentTty.replace(/^\/dev\//, '') !== device)
|
|
45
|
+
break;
|
|
46
|
+
pid = parent;
|
|
47
|
+
}
|
|
48
|
+
const started = psField(machine, pid, 'lstart');
|
|
49
|
+
return started ? { pid, started } : undefined;
|
|
50
|
+
};
|
|
51
|
+
const terminalKey = (machine, atTerminal) => {
|
|
52
|
+
if (!atTerminal)
|
|
53
|
+
return undefined;
|
|
54
|
+
try {
|
|
55
|
+
const tty = psField(machine, machine.pid, 'tty');
|
|
56
|
+
const who = machine.run('who', []);
|
|
57
|
+
return terminalKeyFrom({
|
|
58
|
+
tty,
|
|
59
|
+
who: who.status === 0 ? who.stdout : '',
|
|
60
|
+
leader: () => (tty ? terminalLeader(machine, tty.replace(/^\/dev\//, '')) : undefined)
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
const mintTerminalIdentity = (machine, dir, key) => {
|
|
68
|
+
const { linkSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } = machine.files;
|
|
69
|
+
const file = join(dir, key);
|
|
70
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
71
|
+
const read = () => {
|
|
72
|
+
try {
|
|
73
|
+
const existing = readFileSync(file, 'utf8').trim();
|
|
74
|
+
return existing.length > 0 ? existing : undefined;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
const already = read();
|
|
81
|
+
if (already)
|
|
82
|
+
return already;
|
|
83
|
+
if (key.includes('/') || key.includes('\0'))
|
|
84
|
+
throw new Error(`a session key is one filename: ${key}`);
|
|
85
|
+
const minted = `tty-${randomUUID()}`;
|
|
86
|
+
const temp = join(dir, `.${key}.${machine.pid}.${randomBytes(4).toString('hex')}`);
|
|
87
|
+
writeFileSync(temp, `${minted}\n`, { mode: 0o600 });
|
|
88
|
+
try {
|
|
89
|
+
linkSync(temp, file);
|
|
90
|
+
return minted;
|
|
91
|
+
}
|
|
92
|
+
catch (e) {
|
|
93
|
+
if (e.code !== 'EEXIST')
|
|
94
|
+
throw e;
|
|
95
|
+
const theirs = read();
|
|
96
|
+
if (theirs)
|
|
97
|
+
return theirs;
|
|
98
|
+
throw new Error(`could not read the session identity at ${file}`);
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
try {
|
|
102
|
+
unlinkSync(temp);
|
|
103
|
+
}
|
|
104
|
+
catch { }
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
export const SESSION_ID_SHAPE = /^[\x21-\x7e]{1,128}$/;
|
|
108
|
+
export const AGENT_LABEL_SHAPE = /^[\x20-\x7e]{1,128}$/;
|
|
109
|
+
export const resolveWriteIdentityDetail = (store, atTerminal) => {
|
|
110
|
+
const rawLabel = store.env.LATTRIS_AGENT;
|
|
111
|
+
if (rawLabel !== undefined && rawLabel.length > 0 && !AGENT_LABEL_SHAPE.test(rawLabel)) {
|
|
112
|
+
return {
|
|
113
|
+
kind: 'invalid',
|
|
114
|
+
words: `LATTRIS_AGENT must be 1 to ${SESSION_ID_MAX} printable ASCII characters, the only ones a request header carries; ` +
|
|
115
|
+
`this one is ${rawLabel.length} characters. Nothing was written.`
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
const agentLabel = rawLabel && rawLabel.length > 0 ? rawLabel : undefined;
|
|
119
|
+
const fromEnv = store.env.LATTRIS_SESSION_ID;
|
|
120
|
+
if (fromEnv !== undefined && fromEnv.length > 0) {
|
|
121
|
+
if (!SESSION_ID_SHAPE.test(fromEnv)) {
|
|
122
|
+
return {
|
|
123
|
+
kind: 'invalid',
|
|
124
|
+
words: `LATTRIS_SESSION_ID must be 1 to ${SESSION_ID_MAX} printable characters with no spaces; ` +
|
|
125
|
+
`this one is ${fromEnv.length} characters. Nothing was written.`
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
return { kind: 'ok', identity: { sessionId: fromEnv, agentLabel } };
|
|
129
|
+
}
|
|
130
|
+
const key = terminalKey(store, atTerminal);
|
|
131
|
+
if (key === undefined)
|
|
132
|
+
return { kind: 'none' };
|
|
133
|
+
const sessionId = mintTerminalIdentity(store, join(sessionDir(store), 'sessions'), key);
|
|
134
|
+
return { kind: 'ok', identity: { sessionId, agentLabel } };
|
|
135
|
+
};
|
|
136
|
+
export const requireWriteIdentity = (store, atTerminal) => {
|
|
137
|
+
const r = resolveWriteIdentityDetail(store, atTerminal);
|
|
138
|
+
if (r.kind === 'ok')
|
|
139
|
+
return r.identity;
|
|
140
|
+
return refusal(`${r.kind === 'invalid' ? r.words : SESSION_ID_DOOR}\n`, EXIT.USAGE);
|
|
141
|
+
};
|
|
142
|
+
export const SESSION_ID_DOOR = 'This write changes a note’s history and has to name the session doing it. ' +
|
|
143
|
+
'Off a terminal there is no session to infer: set LATTRIS_SESSION_ID to a stable ' +
|
|
144
|
+
'name for this worker or run (1 to 128 characters), and LATTRIS_AGENT to a readable ' +
|
|
145
|
+
'label if you want one. Nothing was written.';
|
|
146
|
+
export const noteWriteHeaders = (identity, idempotencyKey) => ({
|
|
147
|
+
'X-Lattris-Session-Id': identity.sessionId,
|
|
148
|
+
...(identity.agentLabel ? { 'X-Lattris-Agent': identity.agentLabel } : {}),
|
|
149
|
+
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {})
|
|
150
|
+
});
|
|
151
|
+
const UUID_KEY_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
152
|
+
export const isUuidKey = (key) => UUID_KEY_SHAPE.test(key);
|
|
153
|
+
//# sourceMappingURL=write-identity.js.map
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { dirname, join } from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { readJsonObject } from "./store/json-file.js";
|
|
4
|
+
export const currentPackageVersion = (files) => {
|
|
5
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const pkg = readJsonObject(files, join(here, '..', 'package.json'));
|
|
7
|
+
return typeof pkg?.version === 'string' ? pkg.version : 'unknown';
|
|
8
|
+
};
|
|
9
|
+
export const printVersion = (term, files) => {
|
|
10
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const build = readJsonObject(files, join(here, 'build.json'));
|
|
12
|
+
const version = currentPackageVersion(files);
|
|
13
|
+
const commit = typeof build?.commit === 'string' ? build.commit.slice(0, 12) : undefined;
|
|
14
|
+
term.stdout.write(commit ? `lattris ${version} (${commit})\n` : `lattris ${version}\n`);
|
|
15
|
+
};
|
|
16
|
+
//# sourceMappingURL=version.js.map
|