@mearl/setup 3.0.0-beta.0 → 3.0.0-beta.10

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.
@@ -1,103 +1,15 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { mkdtemp, readFile, readdir, realpath, rm, stat } from 'node:fs/promises';
3
- import { readFileSync, statSync } from 'node:fs';
3
+ import { lstatSync, realpathSync, renameSync } from 'node:fs';
4
+ import { execFileSync } from 'node:child_process';
4
5
  import { homedir, tmpdir } from 'node:os';
5
- import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
6
- import { fileURLToPath } from 'node:url';
6
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
7
7
  import { runCommand } from '@mearl/daemon-core';
8
8
  import { satisfies, valid } from 'semver';
9
- import { DISABLED_SKILL_MANIFEST, findPluginDirectoryEntry, findPluginRegistration, listPluginNames, OFFICIAL_PLUGIN_MANAGER, pluginRegistrationNeedsRefresh, readInstalledSkillMetadata } from '@mearl/client/plugin';
9
+ import { DISABLED_SKILL_MANIFEST, getSkillEnabled, findPluginDirectoryEntry, findPluginRegistration, listPluginNames, OFFICIAL_PLUGIN_MANAGER, pluginRegistrationNeedsRefresh, readInstalledSkillMetadata } from '@mearl/client/plugin';
10
10
  import { existsSync } from 'node:fs';
11
- function lockFile(scope, cwd, options) {
12
- const env = options.env ?? process.env;
13
- return scope === 'project' ? join(cwd, 'skills-lock.json') : env.XDG_STATE_HOME ? join(env.XDG_STATE_HOME, 'skills', '.skill-lock.json') : join(options.homeDirectory ?? homedir(), '.agents', '.skill-lock.json');
14
- }
15
- export function readTrackedSkillSources(scope, cwd, options = {}) {
16
- const file = lockFile(scope, cwd, options);
17
- let text;
18
- try {
19
- if (statSync(file).size > 4 * 1024 * 1024)
20
- throw new Error('Skill source lock exceeds 4 MiB');
21
- text = readFileSync(file, 'utf8');
22
- }
23
- catch (error) {
24
- if (error.code === 'ENOENT')
25
- return [];
26
- throw error;
27
- }
28
- const lock = JSON.parse(text);
29
- if (!lock || !lock.skills || typeof lock.skills !== 'object' || Array.isArray(lock.skills))
30
- throw new Error('Invalid Skills source lock');
31
- return Object.entries(lock.skills).flatMap(([name, entry]) => {
32
- if (!entry || typeof entry !== 'object' || entry.sourceType === 'node_modules')
33
- return [];
34
- if (!name || name === '.' || name === '..' || /[\\/\0\r\n]/.test(name))
35
- throw new Error('Invalid tracked Skill name');
36
- const source = typeof entry.sourceUrl === 'string' ? entry.sourceUrl : entry.source;
37
- if (typeof source !== 'string' || !source.trim())
38
- return [];
39
- if (source.length > 4096 || /[\0\r\n]/.test(source))
40
- throw new Error('Invalid tracked Skill source');
41
- const expectedHash = scope === 'global' ? entry.skillFolderHash : entry.computedHash;
42
- return [{ name, source, scope, ...(typeof entry.sourceType === 'string' ? { sourceType: entry.sourceType } : {}), ...(typeof entry.ref === 'string' ? { ref: entry.ref } : {}), ...(typeof entry.skillPath === 'string' ? { skillPath: entry.skillPath } : {}), ...(typeof expectedHash === 'string' && expectedHash ? { expectedHash } : {}), hashKind: scope === 'global' && typeof expectedHash === 'string' && /^[a-f0-9]{40}$/.test(expectedHash) ? 'git-tree' : 'folder' }];
43
- });
44
- }
45
- /** Custom source kinds require their installed backend, even if they use owner/repo syntax. */
46
- export function isPublicSkillSource(source) {
47
- return source.sourceType === undefined || ['github', 'gitlab', 'git', 'local', 'well-known'].includes(source.sourceType);
48
- }
49
- function gitSource(source, ref) {
50
- if (/^[\w.-]+\/[\w.-]+$/.test(source))
51
- return { repository: `https://github.com/${source}.git`, ...(ref ? { ref } : {}) };
52
- if (/^[\w.-]+@[\w.-]+:[^\s]+$/.test(source))
53
- return { repository: source, ...(ref ? { ref } : {}) };
54
- const url = new URL(source);
55
- if (!['https:', 'http:', 'ssh:', 'git:', 'file:'].includes(url.protocol) || url.password || (url.username && url.protocol !== 'ssh:') || url.search || url.hash)
56
- throw new Error('Skill source is not a supported Git repository');
57
- if (url.hostname === 'github.com' && !url.port) {
58
- const parts = url.pathname.split('/').filter(Boolean);
59
- if (parts.length >= 2)
60
- return { repository: `https://github.com/${parts[0]}/${parts[1].replace(/\.git$/, '')}.git`, ...(ref || (['tree', 'blob'].includes(parts[2]) && parts[3]) ? { ref: ref || decodeURIComponent(parts[3]) } : {}), ...(['tree', 'blob'].includes(parts[2]) && parts.length > 4 ? { subdirectory: decodeURIComponent(parts.slice(4).join('/')).replace(/\/SKILL\.md$/, '') } : {}) };
61
- }
62
- return { repository: url.href, ...(ref ? { ref } : {}) };
63
- }
64
- /** Compare the manager's existing source receipt; no network access or second lockfile. */
65
- export function matchesTrackedSkillSource(requested, tracked, normalize = source => source) {
66
- const location = (source, ref, sourceType) => {
67
- const value = normalize(source, sourceType);
68
- if (typeof value !== 'string' || !value || value.length > 8192 || /[\0\r\n]/.test(value))
69
- throw new Error('Invalid normalized Skill source');
70
- const hash = value.indexOf('#');
71
- const fragment = hash < 0 ? undefined : value.slice(hash + 1);
72
- const plain = hash < 0 ? value : value.slice(0, hash);
73
- const parsed = isAbsolute(plain) ? { repository: plain, ref: ref ?? fragment } : gitSource(plain, ref ?? fragment);
74
- let repository = parsed.repository;
75
- if (repository.startsWith('file:'))
76
- repository = fileURLToPath(repository);
77
- if (isAbsolute(repository))
78
- repository = resolve(repository);
79
- else {
80
- const ssh = repository.match(/^[\w.-]+@([\w.-]+):(.+)$/);
81
- const url = new URL(ssh ? `ssh://${ssh[1]}/${ssh[2]}` : repository);
82
- // Authentication transport is independent of the repository's identity.
83
- repository = `${url.hostname.toLowerCase()}${url.port ? ':' + url.port : ''}/${url.pathname.replace(/^\/+|\/+$/g, '').replace(/\.git$/, '')}`;
84
- }
85
- return { ...parsed, repository, ref: parsed.ref || undefined };
86
- };
87
- try {
88
- const expected = location(requested);
89
- const actual = location(tracked.source, tracked.ref, tracked.sourceType);
90
- if (expected.repository !== actual.repository || expected.ref !== actual.ref)
91
- return false;
92
- if (!expected.subdirectory)
93
- return true;
94
- const path = (tracked.skillPath ?? actual.subdirectory ?? '').replace(/(?:^|\/)SKILL\.md$/, '').replace(/\/+$/, '');
95
- return path === expected.subdirectory || path.startsWith(expected.subdirectory + '/');
96
- }
97
- catch {
98
- return normalize(requested) === normalize(tracked.source, tracked.sourceType) && !tracked.ref;
99
- }
100
- }
11
+ import { gitSource, isPublicSkillSource, matchesTrackedSkillSource, readTrackedSkillSources } from './skillSourceReceipts.js';
12
+ export { isPublicSkillSource, matchesTrackedSkillSource, readTrackedSkillSources } from './skillSourceReceipts.js';
101
13
  function skillName(text) {
102
14
  return text.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1].match(/^name:\s*["']?([^"'\r\n]+?)["']?\s*$/m)?.[1]?.trim();
103
15
  }
@@ -209,16 +121,80 @@ export async function checkTrackedSkillUpdates(options = {}) {
209
121
  await rm(temporary, { recursive: true, force: true });
210
122
  }
211
123
  }
124
+ /** Developer links keep their Git checkout as the source receipt without changing a manager's lock. */
125
+ export function readLinkedSkillSource(directory, name) {
126
+ try {
127
+ if (!lstatSync(directory).isSymbolicLink())
128
+ return undefined;
129
+ const actual = realpathSync(directory);
130
+ const git = (...args) => execFileSync('git', ['--no-optional-locks', '-c', 'core.fsmonitor=false', '-C', actual, ...args], {
131
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 2_000, maxBuffer: 16_384,
132
+ }).trim();
133
+ const root = git('rev-parse', '--show-toplevel');
134
+ const folder = relative(root, actual);
135
+ if (folder === '..' || folder.startsWith('..' + sep) || isAbsolute(folder))
136
+ return undefined;
137
+ const skillPath = (folder ? folder.split(sep).join('/') + '/' : '') + 'SKILL.md';
138
+ if (git('ls-files', '--full-name', '--error-unmatch', '--', 'SKILL.md') !== skillPath)
139
+ return undefined;
140
+ const source = git('config', '--get', 'remote.origin.url');
141
+ if (!source || source.length > 4096 || /[\0\r\n]/.test(source))
142
+ return undefined;
143
+ if (!isAbsolute(source))
144
+ gitSource(source);
145
+ return { name, source, scope: 'global', skillPath, sourceType: 'git', hashKind: 'folder' };
146
+ }
147
+ catch {
148
+ return undefined;
149
+ }
150
+ }
151
+ /** Keep the user's disabled state when an installer replaces the Skill directory. */
152
+ export async function preserveSkillDisabled(directory, operation) {
153
+ const disabled = getSkillEnabled(directory) === false;
154
+ try {
155
+ return await operation();
156
+ }
157
+ finally {
158
+ if (disabled && existsSync(join(directory, 'SKILL.md')))
159
+ renameSync(join(directory, 'SKILL.md'), join(directory, DISABLED_SKILL_MANIFEST));
160
+ }
161
+ }
162
+ function canonicalPath(path) {
163
+ const absolute = resolve(path);
164
+ try {
165
+ return realpathSync(absolute);
166
+ }
167
+ catch {
168
+ const parent = dirname(absolute);
169
+ return parent === absolute ? absolute : join(canonicalPath(parent), basename(absolute));
170
+ }
171
+ }
172
+ function hasOnlySetupRegistrations(directory) {
173
+ const contains = (root, entry) => {
174
+ const path = relative(root, entry);
175
+ return path !== '' && path !== '..' && !path.startsWith('..' + sep) && !isAbsolute(path);
176
+ };
177
+ const registrations = listPluginNames().flatMap(name => findPluginRegistration(name) ?? []).filter(registration => contains(resolve(directory), resolve(registration.entry)) || contains(canonicalPath(directory), canonicalPath(registration.entry)));
178
+ return registrations.length > 0 && registrations.every(registration => registration.managedBy === OFFICIAL_PLUGIN_MANAGER);
179
+ }
212
180
  /** CLI and Agent readiness use the same source, manifest and registration evidence. */
213
181
  export function inspectGlobalSkill(source, name, version, options = {}) {
214
182
  if (!source.trim() || !name || name === '.' || name === '..' || /[\\/\0\r\n]/.test(name))
215
183
  throw new Error('Invalid Skill identity');
216
- const directory = join(homedir(), '.agents', 'skills', name);
184
+ const directory = join(options.homeDirectory ?? homedir(), '.agents', 'skills', name);
217
185
  if (existsSync(join(directory, 'SKILL.md')) && existsSync(join(directory, DISABLED_SKILL_MANIFEST)))
218
186
  return { state: 'conflict', reason: 'SKILL_MANIFEST_CONFLICT' };
219
- const tracked = (options.readSources ?? readTrackedSkillSources)('global').find(entry => entry.name === name);
220
- if (tracked && ((!options.externalManager && !isPublicSkillSource(tracked)) || !matchesTrackedSkillSource(source, tracked, options.normalizeSource)))
187
+ const receipt = (options.readSources ?? readTrackedSkillSources)('global', undefined, { homeDirectory: options.homeDirectory }).find(entry => entry.name === name);
188
+ const tracked = receipt
189
+ ?? (!/(?:#|\/(?:tree|blob)\/)/.test(source) ? readLinkedSkillSource(directory, name) : undefined);
190
+ if (tracked && ((!options.externalManager && !isPublicSkillSource(tracked)) || !matchesTrackedSkillSource(source, tracked, options.normalizeSource))) {
191
+ const metadata = readInstalledSkillMetadata(directory);
192
+ if (receipt && (options.externalManager || isPublicSkillSource(receipt)) && metadata?.name === name &&
193
+ options.migrateFrom?.some(previous => matchesTrackedSkillSource(previous, receipt, options.normalizeSource)) && hasOnlySetupRegistrations(directory)) {
194
+ return { state: 'migration', reason: 'SKILL_SOURCE_MIGRATION_REQUIRED' };
195
+ }
221
196
  return { state: 'conflict', reason: 'SKILL_SOURCE_CONFLICT' };
197
+ }
222
198
  const metadata = readInstalledSkillMetadata(directory);
223
199
  if (!metadata)
224
200
  return { state: 'missing', reason: 'SKILL_NOT_INSTALLED' };
package/dist/skills.js CHANGED
@@ -4,11 +4,11 @@ import { homedir } from 'node:os';
4
4
  import { NodeCapabilityRegistry } from '@mearl/daemon-core/capabilities';
5
5
  import { inspectGlobalSkill, readTrackedSkillSources } from './skillSources.js';
6
6
  import { PresetInstallationStore } from './presetStore.js';
7
- import { findExecutable, isExecutable, prependCommandPath, prependNodePath, runCommand, } from '@mearl/daemon-core';
7
+ import { findExecutable, isExecutable, npmExecEnvironment, prependCommandPath, prependNodePath, runCommand, } from '@mearl/daemon-core';
8
8
  export const SKILLS_CLI_PACKAGE = 'skills@1.5.0';
9
9
  export const MEARL_SKILL_SOURCE = SETUP_VERSION.includes('-beta.')
10
- ? `https://github.com/F-loat/mearl/tree/v${SETUP_VERSION}`
11
- : 'https://github.com/F-loat/mearl';
10
+ ? `https://github.com/mearlhq/mearl/tree/v${SETUP_VERSION}`
11
+ : 'https://github.com/mearlhq/mearl';
12
12
  const COMMAND_TIMEOUT_MS = 120_000;
13
13
  const ANSI_ESCAPE_PATTERN = new RegExp(String.raw `\u001B\[[0-?]*[ -/]*[@-~]`, 'g');
14
14
  export function stripAnsi(value) {
@@ -41,7 +41,7 @@ export function resolveSkillsInvocation(options = {}) {
41
41
  return {
42
42
  command: npx,
43
43
  argsPrefix: ['-y', SKILLS_CLI_PACKAGE],
44
- env: prependNodePath(prependCommandPath(npx, env), processExecPath),
44
+ env: npmExecEnvironment(prependNodePath(prependCommandPath(npx, env), processExecPath)),
45
45
  };
46
46
  }
47
47
  /** Only the selected manager module is needed; browser and Provider factories stay in their hosts. */
@@ -59,9 +59,11 @@ export async function captureSkillsCli(options = {}) {
59
59
  const managedSkills = Object.fromEntries(Object.entries(current.preset?.skills ?? {}).filter(([, entry]) => entry.enabled !== false));
60
60
  const backend = registry.get('skills.manager')?.();
61
61
  if (backend !== undefined) {
62
+ if (backend?.readSources !== undefined && typeof backend.readSources !== 'function')
63
+ throw new Error('Invalid Skill source reader');
62
64
  if (!backend || typeof backend.id !== 'string' || !/^[a-z][a-z0-9-]{0,63}$/.test(backend.id) || typeof backend.cli !== 'function')
63
65
  throw new Error('The selected Skill manager requires a preset update for CLI operations');
64
- return { id: backend.id, managedSkills, selection, inspectGlobal: (source, name, version) => inspectGlobalSkill(source, name, version, { externalManager: true, normalizeSource: backend.normalizeSource, readSources }), run: async (args, settings) => {
66
+ return { id: backend.id, managedSkills, selection, inspectGlobal: (source, name, version, inspection) => inspectGlobalSkill(source, name, version, { migrateFrom: inspection?.migrateFrom, externalManager: true, normalizeSource: backend.normalizeSource, readSources: backend.readSources ?? readSources }), run: async (args, settings) => {
65
67
  const result = await backend.cli([...args], settings);
66
68
  if (!result || !Number.isInteger(result.code) || typeof result.stdout !== 'string' || typeof result.stderr !== 'string')
67
69
  throw new Error('Invalid Skill manager CLI result');
@@ -71,7 +73,7 @@ export async function captureSkillsCli(options = {}) {
71
73
  const invocation = resolveSkillsInvocation(options);
72
74
  if (!invocation)
73
75
  return undefined;
74
- return { id: 'skills', managedSkills, selection, inspectGlobal: (source, name, version) => inspectGlobalSkill(source, name, version, { readSources }), run: (args, settings) => runner(invocation.command, [...invocation.argsPrefix, ...args], { ...settings, env: invocation.env }) };
76
+ return { id: 'skills', managedSkills, selection, inspectGlobal: (source, name, version, inspection) => inspectGlobalSkill(source, name, version, { migrateFrom: inspection?.migrateFrom, readSources }), run: (args, settings) => runner(invocation.command, [...invocation.argsPrefix, ...args], { ...settings, env: invocation.env }) };
75
77
  }
76
78
  export function parseInstalledSkills(output) {
77
79
  const value = JSON.parse(stripAnsi(output).trim());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mearl/setup",
3
- "version": "3.0.0-beta.0",
3
+ "version": "3.0.0-beta.10",
4
4
  "description": "One-shot environment setup and configuration for Mearl",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -45,6 +45,10 @@
45
45
  "./preset": {
46
46
  "types": "./dist/preset.d.ts",
47
47
  "import": "./dist/preset.js"
48
+ },
49
+ "./skill-source-receipts": {
50
+ "types": "./dist/skillSourceReceipts.d.ts",
51
+ "import": "./dist/skillSourceReceipts.js"
48
52
  }
49
53
  },
50
54
  "bin": {
@@ -64,27 +68,33 @@
64
68
  ],
65
69
  "license": "Apache-2.0",
66
70
  "engines": {
67
- "node": ">=22.12.0"
71
+ "node": ">=20.18.0"
68
72
  },
69
73
  "publishConfig": {
70
74
  "registry": "https://registry.npmjs.org"
71
75
  },
72
76
  "dependencies": {
73
- "commander": "^15.0.0",
74
- "extract-zip": "^2.0.1",
77
+ "commander": "^14.0.3",
75
78
  "ws": "^8.18.3",
76
79
  "semver": "^7.8.5",
77
- "@mearl/client": "3.0.0-beta.0",
78
- "@mearl/provider": "3.0.0-beta.0",
79
- "@mearl/daemon-core": "3.0.0-beta.0",
80
- "@mearl/preset": "3.0.0-beta.0",
81
- "@mearl/browser-core": "3.0.0-beta.0"
80
+ "adm-zip": "^0.6.1",
81
+ "@mearl/provider": "3.0.0-beta.10",
82
+ "@mearl/daemon-core": "3.0.0-beta.10",
83
+ "@mearl/client": "3.0.0-beta.10",
84
+ "@mearl/preset": "3.0.0-beta.10",
85
+ "@mearl/browser-core": "3.0.0-beta.10"
82
86
  },
83
87
  "devDependencies": {
84
88
  "@types/node": "^24.9.1",
85
89
  "@types/ws": "^8.18.1",
86
90
  "typescript": "^5.8.3",
87
- "@types/semver": "^7.7.1"
91
+ "@types/semver": "^7.7.1",
92
+ "@types/adm-zip": "^0.5.7"
93
+ },
94
+ "repository": {
95
+ "type": "git",
96
+ "url": "git+https://github.com/mearlhq/mearl.git",
97
+ "directory": "packages/setup"
88
98
  },
89
99
  "scripts": {
90
100
  "build": "rm -rf dist/ && tsc && chmod +x dist/bin.js",
@@ -1,13 +0,0 @@
1
- import { type InstallationConfig } from '@mearl/preset/installation';
2
- import { PresetInstaller, type StagedPresetInstallation } from './preset.js';
3
- import { type DetectedGlobalPackage } from './packageManager.js';
4
- export declare const MEARL_INSTALLATION_CONFIG_URL = "https://f-loat.github.io/mearl/installation.json";
5
- /** Reuse the installation receipt as the retry marker; organization defaults come from deployment data. */
6
- export declare function prepareSetupPreset(options: {
7
- source?: string;
8
- installed?: Array<Pick<DetectedGlobalPackage, 'packageName' | 'packageRoot'>>;
9
- env?: NodeJS.ProcessEnv;
10
- }, dependencies?: {
11
- installer?: PresetInstaller;
12
- configuration?: () => Promise<InstallationConfig>;
13
- }): Promise<StagedPresetInstallation | undefined>;
@@ -1,42 +0,0 @@
1
- import { readFileSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- import { loadInstallationConfig } from '@mearl/preset/installation';
4
- import { PresetInstaller } from './preset.js';
5
- import { detectGlobalPackage } from './packageManager.js';
6
- import { detectMearlProfiles } from './profiles.js';
7
- export const MEARL_INSTALLATION_CONFIG_URL = 'https://f-loat.github.io/mearl/installation.json';
8
- function isLegacyPackage(installation) {
9
- try {
10
- const manifest = JSON.parse(readFileSync(join(installation.packageRoot, 'package.json'), 'utf8'));
11
- return manifest.name === installation.packageName && typeof manifest.version === 'string' && /^[0-2]\.\d+\.\d+(?:[-+][\w.-]+)?$/.test(manifest.version);
12
- }
13
- catch {
14
- return false;
15
- }
16
- }
17
- /** Reuse the installation receipt as the retry marker; organization defaults come from deployment data. */
18
- export async function prepareSetupPreset(options, dependencies = {}) {
19
- const env = options.env ?? process.env;
20
- const installer = dependencies.installer ?? new PresetInstaller({ directory: env.MEARL_CAPABILITY_DIR });
21
- if (options.source)
22
- return installer.stage(options.source);
23
- const current = await installer.store.current();
24
- if (current.receipt)
25
- return { revision: current.receipt.revision, activate: () => installer.resume(current.receipt.revision) };
26
- if (current.activation)
27
- return;
28
- const client = options.installed === undefined ? detectGlobalPackage('@mearl/client', ['mearl'], { env }) : null;
29
- const installed = options.installed ?? [...detectMearlProfiles({ env }).values(), ...(client ? [client] : [])];
30
- if (!installed.some(isLegacyPackage))
31
- return;
32
- const configuration = dependencies.configuration ?? (() => {
33
- const url = new URL(env.MEARL_INSTALLATION_CONFIG_URL || MEARL_INSTALLATION_CONFIG_URL);
34
- if (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)))
35
- throw new Error('Installation configuration requires HTTPS or loopback HTTP');
36
- return loadInstallationConfig(url, url.origin, AbortSignal.timeout(5_000));
37
- });
38
- const config = await configuration();
39
- if (!config.legacyPreset)
40
- throw new Error('Legacy upgrade preset is unavailable. Retry when the installation catalog is available, or select a preset with --preset.');
41
- return installer.stage(config.presets[config.legacyPreset].source, { expectedRevision: null, expectedId: config.legacyPreset });
42
- }