@sema-agent/connectors 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,56 @@ All notable changes to this package are documented here. Released sections are
4
4
  frozen: once a version is published its section is never edited again; new text
5
5
  goes into the next version's section.
6
6
 
7
+ ## 0.3.0 (2026-10-15)
8
+
9
+ ### Added
10
+
11
+ - The default catalog seed (CN-009): `sema-connector seed --config-dir
12
+ <absolute path>`, the launcher's second command, and `seed({ configDir })`
13
+ mount every installed connector of the default catalog into
14
+ `config.d/mcp.json`. The catalog is `DEFAULT_CONNECTORS` (today `gitea` and
15
+ `github`, CN-044),
16
+ the one list of default connectors; `SEED_VERSION` is the newest version
17
+ that added a name. Each connector is resolved with the launcher's own rule
18
+ first (one that is not installed gets no entry) and mounted only through
19
+ `mount`, so another entry under its name is never overwritten.
20
+ `config.d/.sema-connector-seed.json` records what each name came to
21
+ (mounted, present, not installed): a name recorded as mounted or present is
22
+ never mounted again, so an entry a person removed stays removed; a conflict
23
+ or a refusal is not recorded and is tried again. Seeds on one directory
24
+ exclude each other with `config.d/.sema-connector-seed.json.lock` (a second
25
+ seed refuses as `locked`). One line per connector on stdout; exit 0 when
26
+ every connector is mounted, present or not installed, 1 otherwise. A marker
27
+ the seed cannot read refuses it before any mount; a marker write that fails
28
+ leaves the old marker as it was and the mounted entries in place.
29
+ - `pinnedUpstream({ connector, upstream, lockFile, handles })` (CN-044): what
30
+ a connector that runs a third-party MCP server takes from the shared layer,
31
+ so that no connector package carries its own copy. `install(configDir)` is
32
+ the install subcommand: this platform's release archive from the source the
33
+ package's `binaries.lock.json` gives (`https`, redirects followed only to
34
+ `https`, or `file` for a local copy of the releases), its sha256 compared
35
+ with the lock before anything is written, then the archive and its
36
+ `upstream` entry kept in `<configuration directory>/connectors/<name>/bin/`
37
+ (directory 0700, program 0755), each file written whole before it replaces
38
+ the old one; nothing changes when the pinned build is there. `start(env)`
39
+ checks `CONNECTOR_DIR`, one `<NAME>_FILE` per handle naming a regular file
40
+ (not a symbolic link) inside it, and the pinned build in the bin directory
41
+ (the same check as install's), and refuses with a sentence naming what to
42
+ run; its `run(args, extra)` starts the program as the connector's child on
43
+ the same standard input, output and error, with the connector's environment
44
+ minus `CONNECTOR_DIR` and the handle paths plus `extra`, passes `SIGTERM`,
45
+ `SIGINT` and `SIGHUP` on, and ends as the program ends. The Gitea connector
46
+ is its first user (moved from its own package) and the GitHub connector its
47
+ second.
48
+
49
+ ### Changed
50
+
51
+ - A launcher command line that starts with neither `run` nor `seed` is
52
+ refused with `expected the command "run" or "seed"` (exit 2, as before).
53
+ - `readStdinJson` with one key names it as "the key" in its refusals
54
+ (`the object must have exactly the key token`); with two or more keys the
55
+ wording is as before.
56
+
7
57
  ## 0.2.0 (2026-10-15)
8
58
 
9
59
  ### Added
package/dist/cli-kit.d.ts CHANGED
@@ -28,6 +28,7 @@ export type StdinJson = {
28
28
  readonly ok: false;
29
29
  readonly problem: string;
30
30
  };
31
+ export declare const listed: (keys: readonly string[]) => string;
31
32
  export declare function readStdinJson(limit: number, keys: readonly string[]): Promise<StdinJson>;
32
33
  export declare function mountCommand(name: string, configDir: string): number;
33
34
  export declare function statusCommand(name: string, configDir: string): Promise<number>;
package/dist/cli-kit.js CHANGED
@@ -63,12 +63,13 @@ export async function readStdinLine(limit) {
63
63
  return { ok: false, problem: 'standard input must hold exactly one line' };
64
64
  return { ok: true, line };
65
65
  }
66
- const listed = (keys) => (keys.length < 2 ? keys.join('') : `${keys.slice(0, -1).join(', ')} and ${keys[keys.length - 1]}`);
66
+ export const listed = (keys) => (keys.length < 2 ? keys.join('') : `${keys.slice(0, -1).join(', ')} and ${keys[keys.length - 1]}`);
67
67
  export async function readStdinJson(limit, keys) {
68
68
  const read = await readStdinLine(limit);
69
69
  if (!read.ok)
70
70
  return read;
71
- const shape = `standard input must be one JSON object with exactly the keys ${listed(keys)}, each a string, on one line`;
71
+ const exactly = keys.length === 1 ? `exactly the key ${listed(keys)}` : `exactly the keys ${listed(keys)}`;
72
+ const shape = `standard input must be one JSON object with ${exactly}, ${keys.length === 1 ? 'a string' : 'each a string'}, on one line`;
72
73
  let value;
73
74
  try {
74
75
  value = JSON.parse(read.line);
@@ -83,7 +84,7 @@ export async function readStdinJson(limit, keys) {
83
84
  const object = value;
84
85
  const own = Object.keys(object);
85
86
  if (own.length !== keys.length || !keys.every((k) => own.includes(k)))
86
- return { ok: false, problem: `the object must have exactly the keys ${listed(keys)}` };
87
+ return { ok: false, problem: `the object must have ${exactly}` };
87
88
  if (!keys.every((k) => typeof object[k] === 'string'))
88
89
  return { ok: false, problem: shape };
89
90
  return { ok: true, values: Object.fromEntries(keys.map((k) => [k, object[k]])) };
package/dist/index.d.ts CHANGED
@@ -6,7 +6,11 @@ export { ASSERTION_HEADER, hostedResolver, standaloneResolver, verifyAssertion }
6
6
  export type { AnonymousPrincipal, AssertionPrincipal, AssertionStep, Principal, PrincipalResolver, PrincipalResult, RequestContext, VerifyOptions, VerifyResult, } from './principal.js';
7
7
  export { mount } from './mount.js';
8
8
  export type { MountOptions, MountRefusal, MountResult } from './mount.js';
9
+ export { DEFAULT_CONNECTORS, SEED_VERSION, seed } from './seed.js';
10
+ export type { DefaultConnector, SeedOptions, SeedReading, SeedRefusal, SeedResult } from './seed.js';
9
11
  export { cliOutput, connectorCli, mountCommand, readStdinJson, readStdinLine, statusCommand } from './cli-kit.js';
10
12
  export type { CliOutput, ConnectorCommands, StdinJson, StdinLine } from './cli-kit.js';
11
13
  export { isLoopbackHttpOrHttps } from './jwks.js';
14
+ export { pinnedUpstream } from './pinned-upstream.js';
15
+ export type { PinnedUpstream, PinnedUpstreamOptions, UpstreamStart } from './pinned-upstream.js';
12
16
  export { writeAll } from './write-all.js';
package/dist/index.js CHANGED
@@ -2,6 +2,8 @@ export { resolveConfigDir } from './config-dir.js';
2
2
  export { openFileSecrets } from './secrets.js';
3
3
  export { ASSERTION_HEADER, hostedResolver, standaloneResolver, verifyAssertion } from './principal.js';
4
4
  export { mount } from './mount.js';
5
+ export { DEFAULT_CONNECTORS, SEED_VERSION, seed } from './seed.js';
5
6
  export { cliOutput, connectorCli, mountCommand, readStdinJson, readStdinLine, statusCommand } from './cli-kit.js';
6
7
  export { isLoopbackHttpOrHttps } from './jwks.js';
8
+ export { pinnedUpstream } from './pinned-upstream.js';
7
9
  export { writeAll } from './write-all.js';
package/dist/launcher.js CHANGED
@@ -1,20 +1,21 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { randomUUID } from 'node:crypto';
3
- import { readFileSync } from 'node:fs';
4
- import { createRequire } from 'node:module';
5
3
  import { isIPv4, isIPv6 } from 'node:net';
6
4
  import { tmpdir } from 'node:os';
7
- import { dirname, join } from 'node:path';
5
+ import { join } from 'node:path';
8
6
  import { absoluteDir, given } from './config-dir.js';
9
7
  import { CONNECTOR_NAME } from './connector-name.js';
10
8
  import { serveHttp } from './http.js';
11
9
  import { keySetSource, obtainKeySet } from './jwks.js';
12
10
  import { hostedResolver, standaloneResolver } from './principal.js';
13
11
  import { Scrubber, secretMatcher } from './redact.js';
12
+ import { resolveConnector } from './resolve-connector.js';
14
13
  import { NO_HANDLES, copyForLaunch, handleDirectory } from './secrets.js';
14
+ import { seed } from './seed.js';
15
15
  import { INSPECT_LIMIT_BYTES, Session } from './session.js';
16
16
  import { mintStatus } from './status.js';
17
- const EXIT = { ok: 0, guard: 2, notInstalled: 4, connectorFailed: 5 };
17
+ import { writeFailure } from './write-all.js';
18
+ const EXIT = { ok: 0, notSeeded: 1, guard: 2, notInstalled: 4, connectorFailed: 5 };
18
19
  const NOT_INSTALLED = 'connector.not_installed';
19
20
  const INTERNAL = 'connector.internal';
20
21
  const STOP_SIGNALS = ['SIGTERM', 'SIGINT', 'SIGHUP'];
@@ -22,21 +23,15 @@ const TEARDOWN_STEP_MS = 1000;
22
23
  const STALL_STEPS = 10;
23
24
  const STALL_MS = STALL_STEPS * TEARDOWN_STEP_MS;
24
25
  const PIECE_BYTES = 1024;
25
- const USAGE = 'usage: sema-connector run <name> --mode standalone|hosted --transport stdio|http [--listen <ip>:<port>] [--config-dir <absolute path>]';
26
+ const USAGE = 'usage: sema-connector run <name> --mode standalone|hosted --transport stdio|http [--listen <ip>:<port>] [--config-dir <absolute path>]\n sema-connector seed --config-dir <absolute path>';
26
27
  const FLAGS = ['--mode', '--transport', '--listen', '--config-dir'];
28
+ const SEED_FLAGS = ['--config-dir'];
27
29
  const CHILD_ENV = ['HOME', 'LOGNAME', 'USER', 'PATH', 'SHELL', 'TERM', 'LANG', 'LC_ALL', 'TZ'];
28
30
  const CONNECTOR_DIR = 'CONNECTOR_DIR';
29
- function guard(argv, env) {
30
- if (argv[0] !== 'run')
31
- return 'expected the command "run"';
32
- const name = argv[1];
33
- if (name === undefined || name.startsWith('-'))
34
- return 'missing the connector <name> after "run"';
35
- if (!CONNECTOR_NAME.test(name))
36
- return '<name> must be lowercase letters and digits in hyphen-separated words (it names the package @sema-agent/connector-<name>)';
31
+ function flagValues(argv, from, allowed) {
37
32
  const values = new Map();
38
- for (let i = 2; i < argv.length; i += 2) {
39
- const flag = FLAGS.find((f) => f === argv[i]);
33
+ for (let i = from; i < argv.length; i += 2) {
34
+ const flag = allowed.find((f) => f === argv[i]);
40
35
  if (flag === undefined)
41
36
  return `unknown argument at position ${i + 1}`;
42
37
  if (values.has(flag))
@@ -46,6 +41,31 @@ function guard(argv, env) {
46
41
  return `${flag} needs a value`;
47
42
  values.set(flag, value);
48
43
  }
44
+ return values;
45
+ }
46
+ function guard(argv, env) {
47
+ if (argv[0] === 'seed') {
48
+ const values = flagValues(argv, 1, SEED_FLAGS);
49
+ if (typeof values === 'string')
50
+ return values;
51
+ const configDirArgument = values.get('--config-dir');
52
+ if (configDirArgument === undefined)
53
+ return 'seed requires --config-dir <absolute path>, the configuration directory it seeds (there is no default)';
54
+ const configDir = absoluteDir(configDirArgument);
55
+ if (configDir === null)
56
+ return '--config-dir must be an absolute path';
57
+ return { command: 'seed', configDir };
58
+ }
59
+ if (argv[0] !== 'run')
60
+ return 'expected the command "run" or "seed"';
61
+ const name = argv[1];
62
+ if (name === undefined || name.startsWith('-'))
63
+ return 'missing the connector <name> after "run"';
64
+ if (!CONNECTOR_NAME.test(name))
65
+ return '<name> must be lowercase letters and digits in hyphen-separated words (it names the package @sema-agent/connector-<name>)';
66
+ const values = flagValues(argv, 2, FLAGS);
67
+ if (typeof values === 'string')
68
+ return values;
49
69
  const mode = values.get('--mode');
50
70
  const transport = values.get('--transport');
51
71
  const listen = values.get('--listen');
@@ -68,7 +88,7 @@ function guard(argv, env) {
68
88
  const configDir = absoluteDir(configDirArgument);
69
89
  if (configDir === null)
70
90
  return '--config-dir must be an absolute path';
71
- return { name, mode, configDir };
91
+ return { command: 'run', name, mode, configDir };
72
92
  }
73
93
  if (transport !== 'http')
74
94
  return 'hosted mode requires --transport http';
@@ -88,7 +108,7 @@ function guard(argv, env) {
88
108
  const keySet = keySetSource(env, issuer);
89
109
  if (typeof keySet === 'string')
90
110
  return keySet;
91
- return { name, mode, listen: address, issuer, audience, keySet };
111
+ return { command: 'run', name, mode, listen: address, issuer, audience, keySet };
92
112
  }
93
113
  function listenAddress(value) {
94
114
  const m = /^(?:\[([^\]]+)\]|([^:[\]]+)):([1-9][0-9]{0,4})$/.exec(value);
@@ -99,31 +119,6 @@ function listenAddress(value) {
99
119
  const port = Number(m[3]);
100
120
  return hostOk && port <= 65535 ? { host, port } : null;
101
121
  }
102
- function resolveConnector(name) {
103
- const packageName = `@sema-agent/connector-${name}`;
104
- let manifestPath;
105
- try {
106
- manifestPath = createRequire(new URL('../package.json', import.meta.url)).resolve(`${packageName}/package.json`);
107
- }
108
- catch {
109
- return { packageName, reason: 'is not installed where the launcher can resolve it' };
110
- }
111
- let bin;
112
- try {
113
- bin = JSON.parse(readFileSync(manifestPath, 'utf8')).bin;
114
- }
115
- catch {
116
- return { packageName, reason: 'has a package.json that cannot be read' };
117
- }
118
- let rel;
119
- if (typeof bin === 'string')
120
- rel = bin;
121
- else if (bin !== null && typeof bin === 'object' && !Array.isArray(bin) && Object.keys(bin).length === 1)
122
- rel = Object.values(bin)[0];
123
- if (typeof rel !== 'string' || rel === '')
124
- return { packageName, reason: 'does not declare exactly one bin' };
125
- return { packageName, binPath: join(dirname(manifestPath), rel) };
126
- }
127
122
  class Out {
128
123
  stream;
129
124
  held = [];
@@ -527,6 +522,24 @@ async function runHosted(plan, keys, packageName, binPath, env, log) {
527
522
  setTimeout(abandon, TEARDOWN_STEP_MS);
528
523
  return EXIT.ok;
529
524
  }
525
+ const readingLine = (r) => `seed: ${r.name} ${r.outcome}${r.outcome === 'not_installed' ? ` (${r.message})` : r.outcome === 'refused' ? ` (${r.reason}: ${r.message})` : ''}\n`;
526
+ function runSeed(configDir, stdout, log) {
527
+ let result;
528
+ try {
529
+ result = seed({ configDir });
530
+ }
531
+ catch (e) {
532
+ log.line(`sema-connector: seed: failed (${writeFailure(e)}); the marker was not written\n`);
533
+ return EXIT.notSeeded;
534
+ }
535
+ for (const r of result.readings)
536
+ stdout.write(readingLine(r));
537
+ if (!result.ok) {
538
+ log.line(`sema-connector: seed: ${result.reason}: ${result.message}\n`);
539
+ return EXIT.notSeeded;
540
+ }
541
+ return result.readings.every((r) => r.outcome === 'mounted' || r.outcome === 'present' || r.outcome === 'not_installed') ? EXIT.ok : EXIT.notSeeded;
542
+ }
530
543
  export async function main(argv, env) {
531
544
  const stdout = new Out(process.stdout);
532
545
  const log = new Log(new Out(process.stderr));
@@ -539,6 +552,8 @@ export async function main(argv, env) {
539
552
  log.line(`sema-connector: ${plan}\n${USAGE}\n`);
540
553
  return finish(EXIT.guard);
541
554
  }
555
+ if (plan.command === 'seed')
556
+ return finish(runSeed(plan.configDir, stdout, log));
542
557
  if (plan.mode === 'hosted') {
543
558
  const keys = await obtainKeySet(plan.keySet);
544
559
  if (!keys.ok) {
package/dist/mount.d.ts CHANGED
@@ -12,6 +12,10 @@ export type MountResult = {
12
12
  readonly reason: MountRefusal;
13
13
  readonly message: string;
14
14
  };
15
+ export declare function nameRefusal(name: unknown): {
16
+ readonly reason: 'invalid_name' | 'reserved_name';
17
+ readonly message: string;
18
+ } | null;
15
19
  export declare function standaloneEntry(name: string, configDir: string): {
16
20
  name: string;
17
21
  transport: {
package/dist/mount.js CHANGED
@@ -5,8 +5,15 @@ import { absoluteDir } from './config-dir.js';
5
5
  import { CONNECTOR_NAME } from './connector-name.js';
6
6
  import { skipWhitespace, spansOf } from './json-text.js';
7
7
  import { takeLockFile } from './lock-file.js';
8
- import { writeAll } from './write-all.js';
8
+ import { writeAll, writeFailure } from './write-all.js';
9
9
  const RESERVED_NAMES = ['sema-connector'];
10
+ export function nameRefusal(name) {
11
+ if (typeof name !== 'string' || !CONNECTOR_NAME.test(name))
12
+ return { reason: 'invalid_name', message: 'a connector name is lowercase letters and digits in hyphen-separated words' };
13
+ if (RESERVED_NAMES.includes(name))
14
+ return { reason: 'reserved_name', message: `${name} is a reserved name` };
15
+ return null;
16
+ }
10
17
  export function standaloneEntry(name, configDir) {
11
18
  return { name, transport: { kind: 'stdio', command: 'sema-connector', args: ['run', name, '--mode', 'standalone', '--transport', 'stdio', '--config-dir', configDir] } };
12
19
  }
@@ -71,10 +78,6 @@ function plan(before, entry) {
71
78
  return { text };
72
79
  }
73
80
  const LOCK_FILE = '.mcp.json.lock';
74
- const failure = (e) => {
75
- const code = e?.code;
76
- return typeof code === 'string' ? code : e instanceof Error ? e.message : 'unknown error';
77
- };
78
81
  const readOrNull = (file) => {
79
82
  try {
80
83
  return readFileSync(file, 'utf8');
@@ -88,10 +91,9 @@ const readOrNull = (file) => {
88
91
  export function mount(options) {
89
92
  const refuse = (reason, message) => ({ ok: false, reason, message });
90
93
  const name = options?.name;
91
- if (typeof name !== 'string' || !CONNECTOR_NAME.test(name))
92
- return refuse('invalid_name', 'a connector name is lowercase letters and digits in hyphen-separated words');
93
- if (RESERVED_NAMES.includes(name))
94
- return refuse('reserved_name', `${name} is a reserved name`);
94
+ const named = nameRefusal(name);
95
+ if (named !== null)
96
+ return refuse(named.reason, named.message);
95
97
  const configDir = absoluteDir(options.configDir);
96
98
  if (configDir === null)
97
99
  return refuse('config_dir', 'configDir must be the configuration directory as an absolute path; mount reads no environment variable and has no default');
@@ -103,7 +105,7 @@ export function mount(options) {
103
105
  unlock = takeLockFile(join(dir, LOCK_FILE));
104
106
  }
105
107
  catch (e) {
106
- return refuse('write_failed', `config.d or its lock could not be written (${failure(e)}); config.d/mcp.json was left as it was`);
108
+ return refuse('write_failed', `config.d or its lock could not be written (${writeFailure(e)}); config.d/mcp.json was left as it was`);
107
109
  }
108
110
  if (unlock === null)
109
111
  return refuse('locked', `another mount is writing config.d/mcp.json; try again, and if no mount is running remove config.d/${LOCK_FILE}`);
@@ -154,7 +156,7 @@ function write(file, entry, refuse) {
154
156
  }
155
157
  catch (e) {
156
158
  rmSync(tmp, { force: true });
157
- return refuse('write_failed', `config.d/mcp.json could not be written (${failure(e)}); it was left as it was and nothing else was written`);
159
+ return refuse('write_failed', `config.d/mcp.json could not be written (${writeFailure(e)}); it was left as it was and nothing else was written`);
158
160
  }
159
161
  return { ok: true, changed: true, file };
160
162
  }
@@ -0,0 +1,21 @@
1
+ import { type Env } from './config-dir.js';
2
+ export interface PinnedUpstreamOptions {
3
+ readonly connector: string;
4
+ readonly upstream: string;
5
+ readonly lockFile: URL;
6
+ readonly handles: readonly string[];
7
+ }
8
+ export type UpstreamStart = {
9
+ readonly ok: true;
10
+ readonly connectorDir: string;
11
+ readonly files: Readonly<Record<string, string>>;
12
+ run(args: readonly string[], extra: Readonly<Record<string, string>>): Promise<number>;
13
+ } | {
14
+ readonly ok: false;
15
+ readonly problem: string;
16
+ };
17
+ export interface PinnedUpstream {
18
+ install(configDir: string): Promise<number>;
19
+ start(env: Env): UpstreamStart;
20
+ }
21
+ export declare function pinnedUpstream(options: PinnedUpstreamOptions): PinnedUpstream;
@@ -0,0 +1,347 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createHash, randomBytes } from 'node:crypto';
3
+ import { chmodSync, closeSync, constants, fchmodSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync } from 'node:fs';
4
+ import { get as httpsGet } from 'node:https';
5
+ import { constants as osConstants } from 'node:os';
6
+ import { dirname, isAbsolute, join, relative, sep } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { cliOutput, listed } from './cli-kit.js';
9
+ import { given } from './config-dir.js';
10
+ import { CONNECTOR_NAME } from './connector-name.js';
11
+ import { entryOf } from './release-archive.js';
12
+ import { HANDLE_NAME, openFileSecrets } from './secrets.js';
13
+ import { writeAll } from './write-all.js';
14
+ const CONNECTOR_DIR = 'CONNECTOR_DIR';
15
+ const BIN_DIR = 'bin';
16
+ const DIRECTORY_MODE = 0o700;
17
+ const BINARY_MODE = 0o755;
18
+ const ARCHIVE_MODE = 0o644;
19
+ const DOWNLOAD_LIMIT = 256 * 1024 * 1024;
20
+ const DOWNLOAD_IDLE_MS = 120_000;
21
+ const REDIRECT_LIMIT = 5;
22
+ const SIGNALS = ['SIGTERM', 'SIGINT', 'SIGHUP'];
23
+ const VERSION = /^[0-9]+\.[0-9]+\.[0-9]+$/;
24
+ const PLATFORM = /^[a-z0-9]+-[a-z0-9]+$/;
25
+ const FILE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
26
+ const SHA256 = /^[0-9a-f]{64}$/;
27
+ const platformKey = () => `${process.platform}-${process.arch}`;
28
+ const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
29
+ const keysAre = (v, keys) => Object.keys(v).sort().join('\0') === [...keys].sort().join('\0');
30
+ const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
31
+ const below = (dir, file) => {
32
+ if (!isAbsolute(file))
33
+ return false;
34
+ const r = relative(dir, file);
35
+ return r !== '' && !isAbsolute(r) && r.split(sep)[0] !== '..';
36
+ };
37
+ const isRegularFile = (path) => {
38
+ try {
39
+ return lstatSync(path).isFile();
40
+ }
41
+ catch {
42
+ return false;
43
+ }
44
+ };
45
+ function regularFile(path) {
46
+ let fd;
47
+ try {
48
+ fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
49
+ }
50
+ catch {
51
+ return null;
52
+ }
53
+ try {
54
+ return fstatSync(fd).isFile() ? readFileSync(fd) : null;
55
+ }
56
+ catch {
57
+ return null;
58
+ }
59
+ finally {
60
+ closeSync(fd);
61
+ }
62
+ }
63
+ function fetchHttps(url, redirects) {
64
+ return new Promise((resolve) => {
65
+ let settled = false;
66
+ const done = (r) => {
67
+ if (settled)
68
+ return;
69
+ settled = true;
70
+ resolve(r);
71
+ };
72
+ const req = httpsGet(url, (res) => {
73
+ const status = res.statusCode ?? 0;
74
+ const location = res.headers.location;
75
+ if (status >= 300 && status < 400 && location !== undefined) {
76
+ res.resume();
77
+ let next;
78
+ try {
79
+ next = new URL(location, url);
80
+ }
81
+ catch {
82
+ return done({ ok: false, problem: 'the download was redirected to something that is not a URL' });
83
+ }
84
+ if (redirects === 0)
85
+ return done({ ok: false, problem: `the download was redirected more than ${REDIRECT_LIMIT} times` });
86
+ if (next.protocol !== 'https:' || next.username !== '' || next.password !== '')
87
+ return done({ ok: false, problem: 'the download was redirected away from https' });
88
+ return void fetchHttps(next, redirects - 1).then(done);
89
+ }
90
+ if (status !== 200) {
91
+ res.resume();
92
+ return done({ ok: false, problem: `the download answered HTTP ${status}` });
93
+ }
94
+ const parts = [];
95
+ let size = 0;
96
+ res.on('data', (d) => {
97
+ size += d.length;
98
+ if (size > DOWNLOAD_LIMIT) {
99
+ req.destroy();
100
+ done({ ok: false, problem: `the archive is larger than ${DOWNLOAD_LIMIT} bytes` });
101
+ }
102
+ else
103
+ parts.push(d);
104
+ });
105
+ res.on('end', () => done({ ok: true, bytes: Buffer.concat(parts) }));
106
+ res.on('error', () => done({ ok: false, problem: 'the download broke off' }));
107
+ });
108
+ req.setTimeout(DOWNLOAD_IDLE_MS, () => req.destroy(new Error(`no data for ${DOWNLOAD_IDLE_MS / 1000} s`)));
109
+ req.on('error', (e) => done({ ok: false, problem: `the download failed (${e.message})` }));
110
+ });
111
+ }
112
+ async function fetchArchive(url) {
113
+ if (url.protocol === 'https:')
114
+ return fetchHttps(url, REDIRECT_LIMIT);
115
+ const path = fileURLToPath(url);
116
+ try {
117
+ const st = statSync(path);
118
+ if (!st.isFile())
119
+ return { ok: false, problem: `${path} is not a file` };
120
+ if (st.size > DOWNLOAD_LIMIT)
121
+ return { ok: false, problem: `the archive is larger than ${DOWNLOAD_LIMIT} bytes` };
122
+ return { ok: true, bytes: readFileSync(path) };
123
+ }
124
+ catch {
125
+ return { ok: false, problem: `${path} cannot be read` };
126
+ }
127
+ }
128
+ export function pinnedUpstream(options) {
129
+ const { connector, upstream, lockFile } = options ?? {};
130
+ if (typeof connector !== 'string' || !CONNECTOR_NAME.test(connector))
131
+ throw new Error('connector must be lowercase letters and digits in hyphen-separated words');
132
+ if (typeof upstream !== 'string' || !FILE.test(upstream))
133
+ throw new Error('upstream must be a file name: letters, digits, dots, underscores and hyphens, starting with a letter or digit');
134
+ if (!(lockFile instanceof URL) || lockFile.protocol !== 'file:')
135
+ throw new Error('lockFile must be a file URL');
136
+ const handles = options.handles;
137
+ if (!Array.isArray(handles) || !handles.every((h) => typeof h === 'string' && HANDLE_NAME.test(h)) || new Set(handles).size !== handles.length)
138
+ throw new Error('handles must list handle names, each once');
139
+ const { program, say, complain } = cliOutput(connector);
140
+ const fileVars = handles.map((h) => `${h}_FILE`);
141
+ const binaryOf = (connectorDir) => join(connectorDir, BIN_DIR, upstream);
142
+ function pinnedBuild() {
143
+ const refuse = (problem) => ({ ok: false, problem: `binaries.lock.json ${problem}` });
144
+ const isBuild = (v) => isObject(v) && keysAre(v, ['file', 'sha256']) && typeof v.file === 'string' && FILE.test(v.file) && v.file !== upstream && typeof v.sha256 === 'string' && SHA256.test(v.sha256);
145
+ let doc;
146
+ try {
147
+ doc = JSON.parse(readFileSync(lockFile, 'utf8'));
148
+ }
149
+ catch {
150
+ return refuse('cannot be read as JSON');
151
+ }
152
+ if (!isObject(doc) || !keysAre(doc, [upstream]))
153
+ return refuse(`must hold exactly one entry, "${upstream}"`);
154
+ const entry = doc[upstream];
155
+ if (!isObject(entry) || !keysAre(entry, ['version', 'source', 'platforms']))
156
+ return refuse('must give exactly version, source and platforms');
157
+ const { version, source, platforms } = entry;
158
+ if (typeof version !== 'string' || !VERSION.test(version))
159
+ return refuse('must give the version as <major>.<minor>.<patch>');
160
+ if (typeof source !== 'string' || !source.includes('{file}'))
161
+ return refuse('must give the source as a URL template naming {file}');
162
+ if (!isObject(platforms) || !Object.entries(platforms).every(([k, v]) => PLATFORM.test(k) && isBuild(v)))
163
+ return refuse('must map each platform to {"file": <archive name>, "sha256": <64 hex digits>}');
164
+ const key = platformKey();
165
+ const build = Object.hasOwn(platforms, key) ? platforms[key] : undefined;
166
+ if (!isBuild(build))
167
+ return refuse(`pins no ${upstream} build for ${key}`);
168
+ return { ok: true, lock: { version, source, platforms: platforms }, build };
169
+ }
170
+ function sourceOf(lock, build) {
171
+ let url;
172
+ try {
173
+ url = new URL(lock.source.replaceAll('{version}', lock.version).replaceAll('{file}', build.file));
174
+ }
175
+ catch {
176
+ return { ok: false, problem: 'the source in binaries.lock.json is not a URL' };
177
+ }
178
+ if (url.protocol === 'https:' && url.username === '' && url.password === '')
179
+ return { ok: true, url };
180
+ if (url.protocol === 'file:' && (url.host === '' || url.host === 'localhost'))
181
+ return { ok: true, url };
182
+ return { ok: false, problem: 'the source in binaries.lock.json must be an https or file URL (an https URL without credentials)' };
183
+ }
184
+ function installedProblem(connectorDir, build) {
185
+ const dir = join(connectorDir, BIN_DIR);
186
+ let st;
187
+ try {
188
+ st = lstatSync(dir);
189
+ }
190
+ catch {
191
+ return `${upstream} is not installed`;
192
+ }
193
+ if (!st.isDirectory() || (st.mode & 0o777) !== DIRECTORY_MODE)
194
+ return 'the bin directory of the connector is not a 0700 directory';
195
+ const archive = regularFile(join(dir, build.file));
196
+ if (archive === null)
197
+ return `${upstream} is not installed (the pinned archive is not there)`;
198
+ if (sha256(archive) !== build.sha256)
199
+ return 'the archive there is not the one binaries.lock.json pins';
200
+ const entry = entryOf(archive, upstream);
201
+ if (!entry.ok)
202
+ return `the pinned archive ${entry.problem}`;
203
+ const binary = binaryOf(connectorDir);
204
+ const bytes = regularFile(binary);
205
+ if (bytes === null || !bytes.equals(entry.bytes))
206
+ return `${upstream} there is not the entry of the pinned archive`;
207
+ if ((lstatSync(binary).mode & 0o777) !== BINARY_MODE)
208
+ return `${upstream} there is not mode 0755`;
209
+ return null;
210
+ }
211
+ function writeAtomically(path, bytes, mode) {
212
+ const tmp = join(dirname(path), `.${upstream}.${randomBytes(6).toString('hex')}.tmp`);
213
+ try {
214
+ const fd = openSync(tmp, 'wx', mode);
215
+ try {
216
+ fchmodSync(fd, mode);
217
+ writeAll(fd, bytes);
218
+ }
219
+ finally {
220
+ closeSync(fd);
221
+ }
222
+ renameSync(tmp, path);
223
+ }
224
+ catch (e) {
225
+ rmSync(tmp, { force: true });
226
+ throw e;
227
+ }
228
+ }
229
+ async function install(configDir) {
230
+ const pinned = pinnedBuild();
231
+ if (!pinned.ok) {
232
+ complain(`${pinned.problem}; nothing was installed`);
233
+ return 1;
234
+ }
235
+ const { lock, build } = pinned;
236
+ const secrets = openFileSecrets({ configDir, connector });
237
+ const before = secrets.list();
238
+ if (!before.ok) {
239
+ complain(`${before.problem}; nothing was installed`);
240
+ return 1;
241
+ }
242
+ const connectorDir = secrets.directory;
243
+ const binDir = join(connectorDir, BIN_DIR);
244
+ if (installedProblem(connectorDir, build) === null) {
245
+ say(`${upstream} ${lock.version} is already installed in ${binDir}; nothing changed`);
246
+ return 0;
247
+ }
248
+ const source = sourceOf(lock, build);
249
+ if (!source.ok) {
250
+ complain(`${source.problem}; nothing was installed`);
251
+ return 1;
252
+ }
253
+ const fetched = await fetchArchive(source.url);
254
+ if (!fetched.ok) {
255
+ complain(`${build.file} could not be fetched: ${fetched.problem}; nothing was installed`);
256
+ return 1;
257
+ }
258
+ const actual = sha256(fetched.bytes);
259
+ if (actual !== build.sha256) {
260
+ complain(`the fetched ${build.file} does not match binaries.lock.json (sha256 ${actual}, pinned ${build.sha256}); nothing was installed`);
261
+ return 1;
262
+ }
263
+ const entry = entryOf(fetched.bytes, upstream);
264
+ if (!entry.ok) {
265
+ complain(`the pinned archive ${build.file} ${entry.problem}; nothing was installed`);
266
+ return 1;
267
+ }
268
+ try {
269
+ mkdirSync(binDir, { recursive: true, mode: DIRECTORY_MODE });
270
+ const after = secrets.list();
271
+ if (!after.ok)
272
+ throw new Error(after.problem);
273
+ const st = lstatSync(binDir);
274
+ if (!st.isDirectory())
275
+ throw new Error('the bin directory of the connector is not a directory');
276
+ chmodSync(binDir, DIRECTORY_MODE);
277
+ writeAtomically(join(binDir, build.file), fetched.bytes, ARCHIVE_MODE);
278
+ writeAtomically(binaryOf(connectorDir), entry.bytes, BINARY_MODE);
279
+ }
280
+ catch (e) {
281
+ complain(`${upstream} was not installed: ${e instanceof Error ? e.message : 'unknown error'}`);
282
+ return 1;
283
+ }
284
+ const problem = installedProblem(connectorDir, build);
285
+ if (problem !== null) {
286
+ complain(`${upstream} was written but does not read back as the pinned build: ${problem}`);
287
+ return 1;
288
+ }
289
+ say(`installed ${upstream} ${lock.version} (${build.file}, sha256 ${build.sha256}) in ${binDir}`);
290
+ return 0;
291
+ }
292
+ function run(binary, args, env) {
293
+ return new Promise((resolve) => {
294
+ const child = spawn(binary, [...args], { stdio: 'inherit', env });
295
+ const forwarders = SIGNALS.map((signal) => [signal, () => void child.kill(signal)]);
296
+ for (const [signal, forward] of forwarders)
297
+ process.on(signal, forward);
298
+ const stopForwarding = () => {
299
+ for (const [signal, forward] of forwarders)
300
+ process.off(signal, forward);
301
+ };
302
+ child.on('error', (e) => {
303
+ stopForwarding();
304
+ complain(`${upstream} could not be started (${e.code ?? e.message})`);
305
+ resolve(1);
306
+ });
307
+ child.on('exit', (code, signal) => {
308
+ stopForwarding();
309
+ if (signal !== null) {
310
+ process.kill(process.pid, signal);
311
+ setTimeout(() => resolve(128 + (osConstants.signals[signal] ?? 0)), 1000);
312
+ return;
313
+ }
314
+ resolve(code ?? 1);
315
+ });
316
+ });
317
+ }
318
+ function start(env) {
319
+ const refuse = (problem) => ({ ok: false, problem });
320
+ const connectorDir = env[CONNECTOR_DIR];
321
+ if (!given(connectorDir) || !isAbsolute(connectorDir))
322
+ return refuse(`${CONNECTOR_DIR} does not name the connector's directory: start the connector with sema-connector run ${connector}`);
323
+ const paths = fileVars.map((v) => env[v]);
324
+ if (!paths.every(given))
325
+ return refuse(`connector ${connector} has no ${listed(handles)} ${handles.length === 1 ? 'handle' : 'handles'} to start ${upstream} with: store ${handles.length === 1 ? 'it' : 'them'} with ${program} auth`);
326
+ if (!paths.every((p) => below(connectorDir, p) && isRegularFile(p)))
327
+ return refuse(`${listed(fileVars)} must name files in the connector's directory (${CONNECTOR_DIR}), as the launcher hands ${handles.length === 1 ? 'it' : 'them'}: start the connector with sema-connector run ${connector}`);
328
+ const pinned = pinnedBuild();
329
+ if (!pinned.ok)
330
+ return refuse(pinned.problem);
331
+ const problem = installedProblem(connectorDir, pinned.build);
332
+ if (problem !== null)
333
+ return refuse(`${problem}: run ${program} install`);
334
+ const own = {};
335
+ for (const [k, v] of Object.entries(env))
336
+ if (v !== undefined && k !== CONNECTOR_DIR && !fileVars.includes(k))
337
+ own[k] = v;
338
+ const binary = binaryOf(connectorDir);
339
+ return {
340
+ ok: true,
341
+ connectorDir,
342
+ files: Object.fromEntries(handles.map((h, i) => [h, paths[i]])),
343
+ run: (args, extra) => run(binary, args, { ...own, ...extra }),
344
+ };
345
+ }
346
+ return { install, start };
347
+ }
@@ -0,0 +1,8 @@
1
+ export type Entry = {
2
+ readonly ok: true;
3
+ readonly bytes: Buffer;
4
+ } | {
5
+ readonly ok: false;
6
+ readonly problem: string;
7
+ };
8
+ export declare function entryOf(archive: Buffer, wanted: string): Entry;
@@ -0,0 +1,96 @@
1
+ import { gunzipSync } from 'node:zlib';
2
+ const BLOCK = 512;
3
+ const UNPACKED_LIMIT = 512 * 1024 * 1024;
4
+ function text(h, at, length) {
5
+ const raw = h.subarray(at, at + length);
6
+ const end = raw.indexOf(0);
7
+ return raw.subarray(0, end === -1 ? length : end).toString('utf8');
8
+ }
9
+ function octal(h, at, length) {
10
+ const t = text(h, at, length).trim();
11
+ return /^[0-7]+$/.test(t) ? parseInt(t, 8) : null;
12
+ }
13
+ function pax(body) {
14
+ const out = {};
15
+ for (let at = 0; at < body.length;) {
16
+ const space = body.indexOf(0x20, at);
17
+ const length = space === -1 ? NaN : Number(body.subarray(at, space).toString('latin1'));
18
+ if (!Number.isInteger(length) || length <= space - at || at + length > body.length)
19
+ return null;
20
+ const record = body.subarray(space + 1, at + length - 1).toString('utf8');
21
+ const eq = record.indexOf('=');
22
+ if (eq === -1)
23
+ return null;
24
+ const key = record.slice(0, eq);
25
+ const value = record.slice(eq + 1);
26
+ if (key === 'path')
27
+ out.path = value;
28
+ if (key === 'size') {
29
+ if (!/^[0-9]+$/.test(value))
30
+ return null;
31
+ out.size = Number(value);
32
+ }
33
+ at += length;
34
+ }
35
+ return out;
36
+ }
37
+ export function entryOf(archive, wanted) {
38
+ const refuse = (problem) => ({ ok: false, problem });
39
+ let tar;
40
+ try {
41
+ tar = gunzipSync(archive, { maxOutputLength: UNPACKED_LIMIT });
42
+ }
43
+ catch {
44
+ return refuse('is not a gzip stream within the size limit');
45
+ }
46
+ const found = [];
47
+ let next = {};
48
+ for (let at = 0;;) {
49
+ if (at === tar.length)
50
+ break;
51
+ if (at + BLOCK > tar.length)
52
+ return refuse('ends inside a header');
53
+ const h = tar.subarray(at, at + BLOCK);
54
+ if (h.every((b) => b === 0))
55
+ break;
56
+ let sum = 0;
57
+ for (let i = 0; i < BLOCK; i++)
58
+ sum += i >= 148 && i < 156 ? 0x20 : (h[i] ?? 0);
59
+ if (octal(h, 148, 8) !== sum)
60
+ return refuse('has a damaged header');
61
+ const type = h[156] === 0 ? '0' : String.fromCharCode(h[156] ?? 0);
62
+ const meta = type === 'x' || type === 'g' || type === 'L';
63
+ const own = octal(h, 124, 12);
64
+ const size = meta ? own : (next.size ?? own);
65
+ if (size === null)
66
+ return refuse('has a damaged header');
67
+ const start = at + BLOCK;
68
+ if (start + size > tar.length)
69
+ return refuse('ends inside an entry');
70
+ const body = tar.subarray(start, start + size);
71
+ at = start + Math.ceil(size / BLOCK) * BLOCK;
72
+ if (type === 'x') {
73
+ const records = pax(body);
74
+ if (records === null)
75
+ return refuse('has a damaged extended header');
76
+ next = { ...next, ...records };
77
+ continue;
78
+ }
79
+ if (type === 'L') {
80
+ next = { ...next, path: body.toString('utf8').replace(/\0+$/, '') };
81
+ continue;
82
+ }
83
+ if (type === 'g')
84
+ continue;
85
+ const prefix = text(h, 257, 6).startsWith('ustar') ? text(h, 345, 155) : '';
86
+ const name = next.path ?? (prefix === '' ? text(h, 0, 100) : `${prefix}/${text(h, 0, 100)}`);
87
+ next = {};
88
+ if ((type === '0' || type === '7') && name.replace(/^\.\//, '') === wanted)
89
+ found.push(Buffer.from(body));
90
+ }
91
+ if (found.length === 0)
92
+ return refuse(`holds no ${wanted}`);
93
+ if (found.length > 1)
94
+ return refuse(`holds ${wanted} more than once`);
95
+ return { ok: true, bytes: found[0] };
96
+ }
@@ -0,0 +1,8 @@
1
+ export type Resolution = {
2
+ readonly packageName: string;
3
+ readonly binPath: string;
4
+ } | {
5
+ readonly packageName: string;
6
+ readonly reason: string;
7
+ };
8
+ export declare function resolveConnector(name: string): Resolution;
@@ -0,0 +1,28 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import { dirname, join } from 'node:path';
4
+ export function resolveConnector(name) {
5
+ const packageName = `@sema-agent/connector-${name}`;
6
+ let manifestPath;
7
+ try {
8
+ manifestPath = createRequire(new URL('../package.json', import.meta.url)).resolve(`${packageName}/package.json`);
9
+ }
10
+ catch {
11
+ return { packageName, reason: 'is not installed where the launcher can resolve it' };
12
+ }
13
+ let bin;
14
+ try {
15
+ bin = JSON.parse(readFileSync(manifestPath, 'utf8')).bin;
16
+ }
17
+ catch {
18
+ return { packageName, reason: 'has a package.json that cannot be read' };
19
+ }
20
+ let rel;
21
+ if (typeof bin === 'string')
22
+ rel = bin;
23
+ else if (bin !== null && typeof bin === 'object' && !Array.isArray(bin) && Object.keys(bin).length === 1)
24
+ rel = Object.values(bin)[0];
25
+ if (typeof rel !== 'string' || rel === '')
26
+ return { packageName, reason: 'does not declare exactly one bin' };
27
+ return { packageName, binPath: join(dirname(manifestPath), rel) };
28
+ }
package/dist/seed.d.ts ADDED
@@ -0,0 +1,35 @@
1
+ import { type MountRefusal } from './mount.js';
2
+ export interface DefaultConnector {
3
+ readonly name: string;
4
+ readonly since: number;
5
+ }
6
+ export declare const DEFAULT_CONNECTORS: readonly DefaultConnector[];
7
+ export declare const SEED_VERSION: number;
8
+ export interface SeedOptions {
9
+ readonly configDir: string;
10
+ }
11
+ export type SeedReading = {
12
+ readonly name: string;
13
+ readonly outcome: 'mounted' | 'present' | 'conflict';
14
+ } | {
15
+ readonly name: string;
16
+ readonly outcome: 'not_installed';
17
+ readonly message: string;
18
+ } | {
19
+ readonly name: string;
20
+ readonly outcome: 'refused';
21
+ readonly reason: MountRefusal;
22
+ readonly message: string;
23
+ };
24
+ export type SeedRefusal = 'config_dir' | 'locked' | 'unreadable' | 'unexpected_shape' | 'write_failed';
25
+ export type SeedResult = {
26
+ readonly ok: true;
27
+ readonly readings: readonly SeedReading[];
28
+ readonly marker: string;
29
+ } | {
30
+ readonly ok: false;
31
+ readonly reason: SeedRefusal;
32
+ readonly message: string;
33
+ readonly readings: readonly SeedReading[];
34
+ };
35
+ export declare function seed(options: SeedOptions): SeedResult;
package/dist/seed.js ADDED
@@ -0,0 +1,134 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { closeSync, mkdirSync, openSync, readFileSync, renameSync, rmSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { absoluteDir } from './config-dir.js';
5
+ import { hasRepeatedKey } from './json-text.js';
6
+ import { mount, nameRefusal } from './mount.js';
7
+ import { takeLockFile } from './lock-file.js';
8
+ import { resolveConnector } from './resolve-connector.js';
9
+ import { writeFailure, writeAll } from './write-all.js';
10
+ export const DEFAULT_CONNECTORS = Object.freeze([Object.freeze({ name: 'gitea', since: 1 }), Object.freeze({ name: 'github', since: 1 })]);
11
+ export const SEED_VERSION = newestVersion(DEFAULT_CONNECTORS);
12
+ function newestVersion(catalog) {
13
+ const names = new Set();
14
+ for (const c of catalog) {
15
+ const refusal = nameRefusal(c.name);
16
+ if (refusal !== null)
17
+ throw new Error(`the default catalog names a connector mount refuses (${refusal.reason})`);
18
+ if (names.has(c.name))
19
+ throw new Error(`the default catalog names ${c.name} twice`);
20
+ names.add(c.name);
21
+ if (!Number.isSafeInteger(c.since) || c.since < 1)
22
+ throw new Error(`the default catalog gives ${c.name} a since that is not a positive integer`);
23
+ }
24
+ if (catalog.length === 0)
25
+ throw new Error('the default catalog is empty');
26
+ return Math.max(...catalog.map((c) => c.since));
27
+ }
28
+ const MARKER_FILE = '.sema-connector-seed.json';
29
+ const SEED_LOCK_FILE = `${MARKER_FILE}.lock`;
30
+ const RECORDED = ['mounted', 'present', 'not_installed'];
31
+ const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
32
+ function readMarker(file) {
33
+ const where = `config.d/${MARKER_FILE}`;
34
+ let text;
35
+ try {
36
+ text = readFileSync(file, 'utf8');
37
+ }
38
+ catch (e) {
39
+ if (e.code === 'ENOENT')
40
+ return { marker: null };
41
+ return { refusal: 'unreadable', message: `${where} cannot be read` };
42
+ }
43
+ let doc;
44
+ try {
45
+ doc = JSON.parse(text);
46
+ }
47
+ catch {
48
+ return { refusal: 'unreadable', message: `${where} is not valid JSON` };
49
+ }
50
+ const shape = `${where} must be one JSON object {"version": <a positive integer>, "entries": {"<name>": "mounted" | "present" | "not_installed"}}`;
51
+ if (!isObject(doc) || Object.keys(doc).sort().join() !== 'entries,version' || hasRepeatedKey(text))
52
+ return { refusal: 'unexpected_shape', message: shape };
53
+ const { version, entries } = doc;
54
+ if (typeof version !== 'number' || !Number.isSafeInteger(version) || version < 1 || !isObject(entries))
55
+ return { refusal: 'unexpected_shape', message: shape };
56
+ for (const [name, value] of Object.entries(entries)) {
57
+ if (nameRefusal(name) !== null || typeof value !== 'string' || !RECORDED.includes(value))
58
+ return { refusal: 'unexpected_shape', message: shape };
59
+ }
60
+ return { marker: { version, entries: entries } };
61
+ }
62
+ export function seed(options) {
63
+ const configDir = absoluteDir(options?.configDir);
64
+ if (configDir === null)
65
+ return { ok: false, reason: 'config_dir', message: 'configDir must be the configuration directory as an absolute path; the seed reads no environment variable and has no default', readings: [] };
66
+ const dir = join(configDir, 'config.d');
67
+ let unlock;
68
+ try {
69
+ mkdirSync(dir, { recursive: true });
70
+ unlock = takeLockFile(join(dir, SEED_LOCK_FILE));
71
+ }
72
+ catch (e) {
73
+ return { ok: false, reason: 'write_failed', message: `config.d or the seed's lock could not be written (${writeFailure(e)}); nothing was mounted`, readings: [] };
74
+ }
75
+ if (unlock === null)
76
+ return { ok: false, reason: 'locked', message: `another seed is running on this directory; nothing was mounted: try again, and if no seed is running remove config.d/${SEED_LOCK_FILE}`, readings: [] };
77
+ try {
78
+ return seedLocked(configDir, dir);
79
+ }
80
+ finally {
81
+ unlock();
82
+ }
83
+ }
84
+ function seedLocked(configDir, dir) {
85
+ const file = join(dir, MARKER_FILE);
86
+ const read = readMarker(file);
87
+ if ('refusal' in read)
88
+ return { ok: false, reason: read.refusal, message: `${read.message}; nothing was mounted`, readings: [] };
89
+ const before = read.marker;
90
+ const entries = { ...before?.entries };
91
+ const readings = [];
92
+ for (const { name } of DEFAULT_CONNECTORS) {
93
+ const recorded = before !== null && Object.hasOwn(before.entries, name) ? before.entries[name] : undefined;
94
+ if (recorded === 'mounted' || recorded === 'present') {
95
+ readings.push({ name, outcome: 'present' });
96
+ continue;
97
+ }
98
+ const resolved = resolveConnector(name);
99
+ if ('reason' in resolved) {
100
+ readings.push({ name, outcome: 'not_installed', message: `package ${resolved.packageName} ${resolved.reason}` });
101
+ entries[name] = 'not_installed';
102
+ continue;
103
+ }
104
+ const r = mount({ name, configDir });
105
+ if (r.ok) {
106
+ const outcome = r.changed ? 'mounted' : 'present';
107
+ readings.push({ name, outcome });
108
+ entries[name] = outcome;
109
+ }
110
+ else if (r.reason === 'conflict')
111
+ readings.push({ name, outcome: 'conflict' });
112
+ else
113
+ readings.push({ name, outcome: 'refused', reason: r.reason, message: r.message });
114
+ }
115
+ const after = { version: SEED_VERSION, entries };
116
+ if (before === null || JSON.stringify(after) !== JSON.stringify(before)) {
117
+ const tmp = join(dir, `${MARKER_FILE}.${randomBytes(6).toString('hex')}.tmp`);
118
+ try {
119
+ const fd = openSync(tmp, 'wx', 0o666);
120
+ try {
121
+ writeAll(fd, JSON.stringify(after, null, 2) + '\n');
122
+ }
123
+ finally {
124
+ closeSync(fd);
125
+ }
126
+ renameSync(tmp, file);
127
+ }
128
+ catch (e) {
129
+ rmSync(tmp, { force: true });
130
+ return { ok: false, reason: 'write_failed', message: `config.d/${MARKER_FILE} could not be written (${writeFailure(e)}); it was left as it was, and the entries mounted stay mounted`, readings };
131
+ }
132
+ }
133
+ return { ok: true, readings, marker: file };
134
+ }
@@ -1 +1,2 @@
1
1
  export declare function writeAll(fd: number, data: string | Uint8Array): void;
2
+ export declare function writeFailure(e: unknown): string;
package/dist/write-all.js CHANGED
@@ -13,3 +13,7 @@ export function writeAll(fd, data) {
13
13
  if (size !== bytes.length)
14
14
  throw new Error(`the file holds ${size} bytes after a write of ${bytes.length}`);
15
15
  }
16
+ export function writeFailure(e) {
17
+ const code = e?.code;
18
+ return typeof code === 'string' ? code : e instanceof Error ? e.message : 'unknown error';
19
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/connectors",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Shared connector layer and the sema-connector launcher (MCP stdio proxy with principal, scope, normalize, status and trace stages).",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",