Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6dc35e5bf2 | ||
|
|
c7ee3cb5ce | ||
|
|
a972249a2b |
@@ -268,6 +268,16 @@ esac
|
|||||||
_build_runtime_bin_prefix() {
|
_build_runtime_bin_prefix() {
|
||||||
local candidates=()
|
local candidates=()
|
||||||
if [ -n "$MOSAIC_RUNTIME_BIN" ]; then candidates+=("$MOSAIC_RUNTIME_BIN"); fi
|
if [ -n "$MOSAIC_RUNTIME_BIN" ]; then candidates+=("$MOSAIC_RUNTIME_BIN"); fi
|
||||||
|
# A host with no system Node gets one bootstrapped here by tools/install.sh, which
|
||||||
|
# records it in ~/.profile. The fleet unit runs `env -i ... bash --noprofile --norc`
|
||||||
|
# by design, so ~/.profile is never read and the directory has to be named here.
|
||||||
|
# The npm probe below cannot cover this: it reports a package prefix
|
||||||
|
# (~/.npm-global), never a Node runtime directory. It sits ahead of the npm probe so
|
||||||
|
# the bootstrapped runtime wins on a host that has both — that is the one the installer
|
||||||
|
# verified — while an explicit MOSAIC_RUNTIME_BIN still outranks it.
|
||||||
|
# Runtime binaries are `#!/usr/bin/env node`, so without this the pane resolves the
|
||||||
|
# binary and then dies on `env: 'node': No such file or directory`.
|
||||||
|
candidates+=("$PANE_HOME/.mosaic/node/current/bin")
|
||||||
if command -v npm >/dev/null 2>&1; then
|
if command -v npm >/dev/null 2>&1; then
|
||||||
local npm_prefix
|
local npm_prefix
|
||||||
npm_prefix=$(npm config get prefix 2>/dev/null) || true
|
npm_prefix=$(npm config get prefix 2>/dev/null) || true
|
||||||
|
|||||||
@@ -348,6 +348,93 @@ for blocked in LD_PRELOAD= BASH_ENV= MOSAIC_UNTRUSTED_SENTINEL=; do
|
|||||||
echo "$pane_environment" | grep -qF "$blocked" && fail "runtime pane received $blocked"
|
echo "$pane_environment" | grep -qF "$blocked" && fail "runtime pane received $blocked"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# #1256. On a host with no system Node, tools/install.sh bootstraps one into
|
||||||
|
# ~/.mosaic/node/ and writes that directory to ~/.profile. The fleet unit runs
|
||||||
|
# `env -i ... bash --noprofile --norc`, so ~/.profile is never read — correctly, by
|
||||||
|
# design — and _build_runtime_bin_prefix does not list the bootstrap directory. Its
|
||||||
|
# `npm config get prefix` branch cannot cover the gap either: the installer points
|
||||||
|
# npm's prefix at ~/.npm-global, so that branch contributes the npm-global directory
|
||||||
|
# and never the Node one, however it resolves.
|
||||||
|
#
|
||||||
|
# The property under test is not "the string is in PATH". It is that the pane can
|
||||||
|
# EXECUTE a Node-shebang runtime binary — which is what `mosaic` is
|
||||||
|
# (`#!/usr/bin/env node`) and what actually failed: measured on a greenfield VM as
|
||||||
|
# `env: 'node': No such file or directory` after a clean install that reported success.
|
||||||
|
#
|
||||||
|
# So this case runs the pane for real and requires it to have run. A PATH-substring
|
||||||
|
# assertion would pass on a fix that put the directory in the wrong position, and it
|
||||||
|
# would keep passing if the pane later stopped running for some unrelated reason.
|
||||||
|
: > "$TMUX_CALLS"
|
||||||
|
HOME_NODE="$ROOT/bootstrap-node/.config/mosaic"
|
||||||
|
write_generated "$HOME_NODE" "coder-node"
|
||||||
|
NODE_PANE_HOME="${HOME_NODE%/.config/mosaic}"
|
||||||
|
NODE_BOOTSTRAP_BIN="$NODE_PANE_HOME/.mosaic/node/current/bin"
|
||||||
|
mkdir -p "$NODE_BOOTSTRAP_BIN"
|
||||||
|
|
||||||
|
# The bootstrapped runtime. It records that it ran, which is the evidence this case
|
||||||
|
# turns on: no node reachable from the pane means no marker.
|
||||||
|
cat > "$NODE_BOOTSTRAP_BIN/node" <<'SHIM'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
env -0 > "${MOSAIC_HOME:?}/fleet/pane-environment"
|
||||||
|
SHIM
|
||||||
|
chmod +x "$NODE_BOOTSTRAP_BIN/node"
|
||||||
|
|
||||||
|
# write_generated plants its symlinks under the MOSAIC_HOME it is given; here the
|
||||||
|
# pane's HOME is the trusted parent, so the pane's view of "installed" is this
|
||||||
|
# directory instead. `pi` is what #1241 resolves against PANE_PATH; `mosaic` is what
|
||||||
|
# the pane then executes, and it is a Node script — not a bash script that would run
|
||||||
|
# anywhere and quietly hide the defect.
|
||||||
|
mkdir -p "$NODE_PANE_HOME/.npm-global/bin"
|
||||||
|
ln -sf "$FAKE_BIN/pi" "$NODE_PANE_HOME/.npm-global/bin/pi"
|
||||||
|
printf '#!/usr/bin/env node\n' > "$NODE_PANE_HOME/.npm-global/bin/mosaic"
|
||||||
|
chmod +x "$NODE_PANE_HOME/.npm-global/bin/mosaic"
|
||||||
|
|
||||||
|
# The npm branch is modelled ALIVE and still cannot close the gap, which is the
|
||||||
|
# stronger statement. An earlier draft of this case tried to model npm as absent —
|
||||||
|
# true on a real bootstrap host, where npm lives only in the Node directory — and it
|
||||||
|
# refused to run anywhere npm is in the system path, i.e. most machines. It was also
|
||||||
|
# the weaker claim: it would have proven only that a dead branch supplies nothing.
|
||||||
|
#
|
||||||
|
# On a bootstrap host the installer sets npm's prefix to ~/.npm-global. So even with
|
||||||
|
# `command -v npm` true and the branch executing, `npm config get prefix` yields the
|
||||||
|
# npm-global directory and never the Node one. The gap does not depend on whether
|
||||||
|
# that branch runs.
|
||||||
|
NODE_LAUNCHER_BIN="$ROOT/bootstrap-node-launcher-bin"
|
||||||
|
mkdir -p "$NODE_LAUNCHER_BIN"
|
||||||
|
ln -sf "$FAKE_BIN/tmux" "$NODE_LAUNCHER_BIN/tmux"
|
||||||
|
ln -sf "$FAKE_BIN/npm" "$NODE_LAUNCHER_BIN/npm"
|
||||||
|
|
||||||
|
/usr/bin/env -i \
|
||||||
|
"HOME=$NODE_PANE_HOME" \
|
||||||
|
"PATH=$NODE_LAUNCHER_BIN:/usr/bin:/bin" \
|
||||||
|
"MOSAIC_HOME=$HOME_NODE" \
|
||||||
|
"MOSAIC_TEST_TMUX_CALLS=$TMUX_CALLS" \
|
||||||
|
"MOSAIC_TEST_HOME=$NODE_PANE_HOME" \
|
||||||
|
"MOSAIC_TEST_NPM_PREFIX=$NODE_PANE_HOME/.npm-global" \
|
||||||
|
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
|
||||||
|
MOSAIC_TEST_EXECUTE_PANE=1 \
|
||||||
|
"MOSAIC_TEST_PANE_PID=$$" \
|
||||||
|
"$START" coder-node
|
||||||
|
|
||||||
|
[ -f "$HOME_NODE/fleet/pane-environment" ] || \
|
||||||
|
fail "pane could not execute a Node-shebang runtime: $NODE_BOOTSTRAP_BIN is absent from PANE_PATH (#1256)"
|
||||||
|
node_pane_environment=$(tr '\0' '\n' < "$HOME_NODE/fleet/pane-environment")
|
||||||
|
# Colon-pad and match a whole element. A regex with `(^|:)` after `.*` looks like it
|
||||||
|
# does this and does not: an anchor cannot match mid-pattern, so it silently requires
|
||||||
|
# a leading colon and rejects the directory in FIRST position — which is where THIS
|
||||||
|
# FIXTURE puts it: it runs under `env -i` with no MOSAIC_RUNTIME_BIN, so the bootstrap
|
||||||
|
# directory leads. That is a property of the fixture, not of the fix — in general the
|
||||||
|
# directory sits second, after MOSAIC_RUNTIME_BIN. The colon padding makes the
|
||||||
|
# assertion position-independent either way, which is why it is written this way and
|
||||||
|
# not with an anchor. That produced a failure reading "pane ran but PANE_PATH does not
|
||||||
|
# carry <dir>" against a PATH whose first element was that dir.
|
||||||
|
node_pane_path=":$(printf '%s\n' "$node_pane_environment" | sed -n 's/^PATH=//p' | head -1):"
|
||||||
|
case "$node_pane_path" in
|
||||||
|
*":$NODE_BOOTSTRAP_BIN:"*) ;;
|
||||||
|
*) fail "pane ran but PANE_PATH does not carry $NODE_BOOTSTRAP_BIN (PATH=$node_pane_path)" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
write_interaction_generated() {
|
write_interaction_generated() {
|
||||||
local home="$1"
|
local home="$1"
|
||||||
local agent="$2"
|
local agent="$2"
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import { registerMissionCommand } from './commands/mission.js';
|
|||||||
import { registerUninstallCommand } from './commands/uninstall.js';
|
import { registerUninstallCommand } from './commands/uninstall.js';
|
||||||
import { registerRestoreCommand } from './commands/restore.js';
|
import { registerRestoreCommand } from './commands/restore.js';
|
||||||
import { registerSkillCommand } from './commands/skill.js';
|
import { registerSkillCommand } from './commands/skill.js';
|
||||||
import { registerStoreCommand } from './commands/store.js';
|
|
||||||
// prdy is registered via launch.ts
|
// prdy is registered via launch.ts
|
||||||
import { registerLaunchCommands } from './commands/launch.js';
|
import { registerLaunchCommands } from './commands/launch.js';
|
||||||
import { registerLeaseCapabilityProbe } from './commands/lease-activation-probe.js';
|
import { registerLeaseCapabilityProbe } from './commands/lease-activation-probe.js';
|
||||||
@@ -426,10 +425,6 @@ registerRestoreCommand(program);
|
|||||||
|
|
||||||
registerSkillCommand(program);
|
registerSkillCommand(program);
|
||||||
|
|
||||||
// ─── store ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
registerStoreCommand(program);
|
|
||||||
|
|
||||||
// ─── telemetry ───────────────────────────────────────────────────────────────
|
// ─── telemetry ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
registerTelemetryCommand(program);
|
registerTelemetryCommand(program);
|
||||||
|
|||||||
@@ -1,458 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
||||||
import { Command } from 'commander';
|
|
||||||
import {
|
|
||||||
existsSync,
|
|
||||||
lstatSync,
|
|
||||||
mkdirSync,
|
|
||||||
mkdtempSync,
|
|
||||||
readFileSync,
|
|
||||||
readdirSync,
|
|
||||||
rmSync,
|
|
||||||
symlinkSync,
|
|
||||||
writeFileSync,
|
|
||||||
} from 'node:fs';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import {
|
|
||||||
addStoreEntry,
|
|
||||||
getDefaultStorePaths,
|
|
||||||
listStoreEntries,
|
|
||||||
registerStoreCommand,
|
|
||||||
StoreError,
|
|
||||||
storeKindDir,
|
|
||||||
validateStoreKind,
|
|
||||||
validateStoreName,
|
|
||||||
validateStoreVersion,
|
|
||||||
type StorePaths,
|
|
||||||
} from './store.js';
|
|
||||||
|
|
||||||
/** Assert a typed StoreError with exactly the expected code. */
|
|
||||||
function expectStoreError(run: () => unknown, code: string): void {
|
|
||||||
try {
|
|
||||||
run();
|
|
||||||
} catch (error) {
|
|
||||||
expect(error).toBeInstanceOf(StoreError);
|
|
||||||
expect((error as StoreError).code).toBe(code);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw new Error(`expected StoreError ${code}, but nothing threw`);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('vetted user store (W-F4)', () => {
|
|
||||||
let root: string;
|
|
||||||
let paths: StorePaths;
|
|
||||||
let sourceRoot: string;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
root = mkdtempSync(join(tmpdir(), 'mosaic-store-cli-'));
|
|
||||||
paths = { userRoot: join(root, '.mosaic') };
|
|
||||||
sourceRoot = join(root, 'sources');
|
|
||||||
mkdirSync(sourceRoot, { recursive: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
rmSync(root, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
function createSource(name: string): string {
|
|
||||||
const dir = join(sourceRoot, name);
|
|
||||||
mkdirSync(dir, { recursive: true });
|
|
||||||
writeFileSync(join(dir, 'SKILL.md'), `# ${name}\n`);
|
|
||||||
return dir;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('name and version validation (before any filesystem call)', () => {
|
|
||||||
const invalidNames = [
|
|
||||||
'../../etc',
|
|
||||||
'/abs/path',
|
|
||||||
'a/b',
|
|
||||||
String.raw`a\b`,
|
|
||||||
'-rf',
|
|
||||||
'..',
|
|
||||||
'safe.',
|
|
||||||
'space name',
|
|
||||||
'line\nbreak',
|
|
||||||
'escape\u001B[31m',
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const name of invalidNames) {
|
|
||||||
it(`rejects name ${JSON.stringify(name)}`, () => {
|
|
||||||
expect(() => validateStoreName(name)).toThrow(StoreError);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const invalidVersions = ['', '-1', '1..0', 'a/b', '..', '1.0 beta', '/x'];
|
|
||||||
for (const version of invalidVersions) {
|
|
||||||
it(`rejects version ${JSON.stringify(version)}`, () => {
|
|
||||||
expect(() => validateStoreVersion(version)).toThrow(StoreError);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
it('accepts semver-shaped versions including prerelease and build metadata', () => {
|
|
||||||
expect(() => validateStoreVersion('0.1.0-beta.1')).not.toThrow();
|
|
||||||
expect(() => validateStoreVersion('1.2.3+build.7')).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects plural and unknown kinds', () => {
|
|
||||||
expectStoreError(() => validateStoreKind('plugins'), 'STORE_INVALID_KIND');
|
|
||||||
expectStoreError(() => validateStoreKind('widget'), 'STORE_INVALID_KIND');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts the two spec kinds', () => {
|
|
||||||
expect(() => validateStoreKind('plugin')).not.toThrow();
|
|
||||||
expect(() => validateStoreKind('skill')).not.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('addStoreEntry', () => {
|
|
||||||
it('copies content into a versioned directory and writes the marker last', () => {
|
|
||||||
const result = addStoreEntry(
|
|
||||||
'skill',
|
|
||||||
'demo',
|
|
||||||
'1.0.0',
|
|
||||||
createSource('demo'),
|
|
||||||
'op',
|
|
||||||
undefined,
|
|
||||||
paths,
|
|
||||||
);
|
|
||||||
expect(result.status).toBe('added');
|
|
||||||
const entryPath = join(paths.userRoot, 'skills', 'demo', '1.0.0');
|
|
||||||
expect(result.entryPath).toBe(entryPath);
|
|
||||||
expect(existsSync(join(entryPath, 'SKILL.md'))).toBe(true);
|
|
||||||
expect(existsSync(join(entryPath, 'store-entry.json'))).toBe(true);
|
|
||||||
const meta = JSON.parse(readFileSync(join(entryPath, 'store-entry.json'), 'utf-8'));
|
|
||||||
expect(meta).toMatchObject({
|
|
||||||
schema: 1,
|
|
||||||
kind: 'skill',
|
|
||||||
name: 'demo',
|
|
||||||
version: '1.0.0',
|
|
||||||
vettedBy: 'op',
|
|
||||||
});
|
|
||||||
expect(typeof meta['vettedAt']).toBe('string');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('writes plugins under plugins/ and skills under skills/', () => {
|
|
||||||
addStoreEntry('plugin', 'alpha', '0.1.0', createSource('alpha'), 'op', undefined, paths);
|
|
||||||
addStoreEntry('skill', 'beta', '2.0.0', createSource('beta'), 'op', undefined, paths);
|
|
||||||
expect(existsSync(join(paths.userRoot, 'plugins', 'alpha', '0.1.0'))).toBe(true);
|
|
||||||
expect(existsSync(join(paths.userRoot, 'skills', 'beta', '2.0.0'))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is append-only: an existing version with a marker is refused, not overwritten', () => {
|
|
||||||
const sourceA = createSource('demo');
|
|
||||||
const sourceB = join(sourceRoot, 'demo-other');
|
|
||||||
mkdirSync(sourceB, { recursive: true });
|
|
||||||
writeFileSync(join(sourceB, 'SKILL.md'), '# changed\n');
|
|
||||||
addStoreEntry('skill', 'demo', '1.0.0', sourceA, 'op', undefined, paths);
|
|
||||||
expectStoreError(
|
|
||||||
() => addStoreEntry('skill', 'demo', '1.0.0', sourceB, 'op', undefined, paths),
|
|
||||||
'STORE_ALREADY_PRESENT',
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
readFileSync(join(paths.userRoot, 'skills', 'demo', '1.0.0', 'SKILL.md'), 'utf-8'),
|
|
||||||
).toBe('# demo\n');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('allows a second version alongside the first', () => {
|
|
||||||
addStoreEntry('skill', 'demo', '1.0.0', createSource('demo'), 'op', undefined, paths);
|
|
||||||
const result = addStoreEntry(
|
|
||||||
'skill',
|
|
||||||
'demo',
|
|
||||||
'1.1.0',
|
|
||||||
createSource('demo'),
|
|
||||||
'op',
|
|
||||||
undefined,
|
|
||||||
paths,
|
|
||||||
);
|
|
||||||
expect(result.status).toBe('added');
|
|
||||||
expect(readdirSync(join(paths.userRoot, 'skills', 'demo')).sort()).toEqual([
|
|
||||||
'1.0.0',
|
|
||||||
'1.1.0',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses an unmarked target directory by default and preserves its content', () => {
|
|
||||||
const unmarked = join(paths.userRoot, 'skills', 'demo', '1.0.0');
|
|
||||||
mkdirSync(unmarked, { recursive: true });
|
|
||||||
writeFileSync(join(unmarked, 'SKILL.md'), '# operator content\n');
|
|
||||||
expectStoreError(
|
|
||||||
() => addStoreEntry('skill', 'demo', '1.0.0', createSource('demo'), 'op', undefined, paths),
|
|
||||||
'STORE_TARGET_UNMARKED',
|
|
||||||
);
|
|
||||||
// The operator's hand-placed content survives the refusal.
|
|
||||||
expect(readFileSync(join(unmarked, 'SKILL.md'), 'utf-8')).toBe('# operator content\n');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reclaims an unmarked target only under explicit reclaim opt-in', () => {
|
|
||||||
const unmarked = join(paths.userRoot, 'skills', 'demo', '1.0.0');
|
|
||||||
mkdirSync(unmarked, { recursive: true });
|
|
||||||
writeFileSync(join(unmarked, 'SKILL.md'), '# torn write\n');
|
|
||||||
const result = addStoreEntry(
|
|
||||||
'skill',
|
|
||||||
'demo',
|
|
||||||
'1.0.0',
|
|
||||||
createSource('demo'),
|
|
||||||
'op',
|
|
||||||
undefined,
|
|
||||||
paths,
|
|
||||||
{ reclaim: true },
|
|
||||||
);
|
|
||||||
expect(result.status).toBe('reclaimed-unmarked');
|
|
||||||
expect(readFileSync(join(unmarked, 'SKILL.md'), 'utf-8')).toBe('# demo\n');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reclaim can never destroy a marked, vetted entry (append-only holds under --reclaim)', () => {
|
|
||||||
addStoreEntry('skill', 'demo', '1.0.0', createSource('demo'), 'op', undefined, paths);
|
|
||||||
const marked = join(paths.userRoot, 'skills', 'demo', '1.0.0');
|
|
||||||
const vettedContent = readFileSync(join(marked, 'SKILL.md'), 'utf-8');
|
|
||||||
expectStoreError(
|
|
||||||
() =>
|
|
||||||
addStoreEntry('skill', 'demo', '1.0.0', createSource('demo'), 'op', undefined, paths, {
|
|
||||||
reclaim: true,
|
|
||||||
}),
|
|
||||||
'STORE_ALREADY_PRESENT',
|
|
||||||
);
|
|
||||||
// Load-bearing half: the throw alone does not prove nothing was deleted
|
|
||||||
// before it. Pins the marker-check-before-reclaim-check ordering against
|
|
||||||
// the guard-clause-migrates-upward refactor (review finding on b2124c6).
|
|
||||||
expect(readFileSync(join(marked, 'SKILL.md'), 'utf-8')).toBe(vettedContent);
|
|
||||||
expect(existsSync(join(marked, 'store-entry.json'))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses a missing source with a typed error', () => {
|
|
||||||
expectStoreError(
|
|
||||||
() =>
|
|
||||||
addStoreEntry('skill', 'demo', '1.0.0', join(sourceRoot, 'nope'), 'op', undefined, paths),
|
|
||||||
'STORE_SOURCE_MISSING',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses a file (non-directory) source with a typed error', () => {
|
|
||||||
const filePath = join(sourceRoot, 'file.txt');
|
|
||||||
writeFileSync(filePath, 'x');
|
|
||||||
expectStoreError(
|
|
||||||
() => addStoreEntry('skill', 'demo', '1.0.0', filePath, 'op', undefined, paths),
|
|
||||||
'STORE_SOURCE_NOT_DIR',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses a symlinked source with a typed error and writes nothing', () => {
|
|
||||||
const real = createSource('demo');
|
|
||||||
const link = join(sourceRoot, 'demo-link');
|
|
||||||
symlinkSync(real, link);
|
|
||||||
expectStoreError(
|
|
||||||
() => addStoreEntry('skill', 'demo', '1.0.0', link, 'op', undefined, paths),
|
|
||||||
'STORE_SOURCE_SYMLINK',
|
|
||||||
);
|
|
||||||
expect(existsSync(join(paths.userRoot, 'skills', 'demo'))).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses a source tree containing nested symlinks and writes nothing', () => {
|
|
||||||
const src = createSource('demo');
|
|
||||||
const target = join(sourceRoot, 'elsewhere');
|
|
||||||
mkdirSync(target, { recursive: true });
|
|
||||||
symlinkSync(target, join(src, 'escape'));
|
|
||||||
expectStoreError(
|
|
||||||
() => addStoreEntry('skill', 'demo', '1.0.0', src, 'op', undefined, paths),
|
|
||||||
'STORE_SOURCE_SYMLINK',
|
|
||||||
);
|
|
||||||
expect(existsSync(join(paths.userRoot, 'skills', 'demo'))).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses adding from inside the store itself', () => {
|
|
||||||
const first = addStoreEntry(
|
|
||||||
'skill',
|
|
||||||
'demo',
|
|
||||||
'1.0.0',
|
|
||||||
createSource('demo'),
|
|
||||||
'op',
|
|
||||||
undefined,
|
|
||||||
paths,
|
|
||||||
);
|
|
||||||
expectStoreError(
|
|
||||||
() => addStoreEntry('skill', 'copy', '1.0.0', first.entryPath, 'op', undefined, paths),
|
|
||||||
'STORE_SOURCE_INSIDE_STORE',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses a symlinked user root ancestor', () => {
|
|
||||||
const linkedRoot = join(sourceRoot, 'linked-mosaic');
|
|
||||||
symlinkSync(paths.userRoot, linkedRoot);
|
|
||||||
expectStoreError(
|
|
||||||
() =>
|
|
||||||
addStoreEntry('skill', 'demo', '1.0.0', createSource('demo'), 'op', undefined, {
|
|
||||||
userRoot: linkedRoot,
|
|
||||||
}),
|
|
||||||
'STORE_SYMLINK_ROOT',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('requires a non-empty vetting attribution', () => {
|
|
||||||
expectStoreError(
|
|
||||||
() => addStoreEntry('skill', 'demo', '1.0.0', createSource('demo'), ' ', undefined, paths),
|
|
||||||
'STORE_INVALID_VETTER',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('listStoreEntries', () => {
|
|
||||||
it('returns empty for an absent store without creating it', () => {
|
|
||||||
expect(listStoreEntries(paths)).toEqual([]);
|
|
||||||
expect(existsSync(paths.userRoot)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('lists entries deterministically with vetting metadata', () => {
|
|
||||||
addStoreEntry('plugin', 'alpha', '0.1.0', createSource('alpha'), 'fred', undefined, paths);
|
|
||||||
addStoreEntry('skill', 'beta', '2.0.0', createSource('beta'), 'fargo', 'looked fine', paths);
|
|
||||||
addStoreEntry('skill', 'beta', '2.1.0', createSource('beta'), 'fargo', undefined, paths);
|
|
||||||
|
|
||||||
const entries = listStoreEntries(paths);
|
|
||||||
expect(entries.map((e) => `${e.kind}:${e.name}:${e.version}`)).toEqual([
|
|
||||||
'plugin:alpha:0.1.0',
|
|
||||||
'skill:beta:2.0.0',
|
|
||||||
'skill:beta:2.1.0',
|
|
||||||
]);
|
|
||||||
expect(entries[0]?.meta?.vettedBy).toBe('fred');
|
|
||||||
expect(entries[1]?.meta?.notes).toBe('looked fine');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('classifies markerless version directories as incomplete', () => {
|
|
||||||
addStoreEntry('skill', 'demo', '1.0.0', createSource('demo'), 'op', undefined, paths);
|
|
||||||
mkdirSync(join(paths.userRoot, 'skills', 'demo', '2.0.0'), { recursive: true });
|
|
||||||
const entries = listStoreEntries(paths, { kind: 'skill', name: 'demo' });
|
|
||||||
expect(entries.find((e) => e.version === '1.0.0')?.status).toBe('vetted');
|
|
||||||
expect(entries.find((e) => e.version === '2.0.0')?.status).toBe('incomplete');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('classifies malformed marker JSON as invalid-metadata, not vetted', () => {
|
|
||||||
addStoreEntry('skill', 'demo', '1.0.0', createSource('demo'), 'op', undefined, paths);
|
|
||||||
writeFileSync(
|
|
||||||
join(paths.userRoot, 'skills', 'demo', '1.0.0', 'store-entry.json'),
|
|
||||||
'{not json',
|
|
||||||
);
|
|
||||||
const entries = listStoreEntries(paths);
|
|
||||||
expect(entries[0]?.status).toBe('invalid-metadata');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('surfaces foreign files (never mutates them)', () => {
|
|
||||||
mkdirSync(join(paths.userRoot, 'skills'), { recursive: true });
|
|
||||||
writeFileSync(join(paths.userRoot, 'skills', 'stray.txt'), 'x');
|
|
||||||
const entries = listStoreEntries(paths);
|
|
||||||
expect(entries[0]?.status).toBe('foreign');
|
|
||||||
expect(existsSync(join(paths.userRoot, 'skills', 'stray.txt'))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('filters by kind and name', () => {
|
|
||||||
addStoreEntry('plugin', 'alpha', '0.1.0', createSource('alpha'), 'op', undefined, paths);
|
|
||||||
addStoreEntry('skill', 'beta', '1.0.0', createSource('beta'), 'op', undefined, paths);
|
|
||||||
expect(listStoreEntries(paths, { kind: 'plugin' }).map((e) => e.name)).toEqual(['alpha']);
|
|
||||||
expect(listStoreEntries(paths, { name: 'beta' }).map((e) => e.name)).toEqual(['beta']);
|
|
||||||
expect(() => listStoreEntries(paths, { name: '../escape' })).toThrow(StoreError);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('default paths seam', () => {
|
|
||||||
it('honors MOSAIC_USER_HOME', () => {
|
|
||||||
const previous = process.env['MOSAIC_USER_HOME'];
|
|
||||||
try {
|
|
||||||
process.env['MOSAIC_USER_HOME'] = join(root, 'custom-user-home');
|
|
||||||
expect(getDefaultStorePaths().userRoot).toBe(join(root, 'custom-user-home'));
|
|
||||||
expect(storeKindDir('plugin')).toBe(join(root, 'custom-user-home', 'plugins'));
|
|
||||||
} finally {
|
|
||||||
if (previous === undefined) delete process.env['MOSAIC_USER_HOME'];
|
|
||||||
else process.env['MOSAIC_USER_HOME'] = previous;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('CLI', () => {
|
|
||||||
let previousExitCode: string | number | null | undefined;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
previousExitCode = process.exitCode;
|
|
||||||
process.exitCode = undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
process.exitCode = previousExitCode;
|
|
||||||
});
|
|
||||||
|
|
||||||
const parse = (args: string[]) => {
|
|
||||||
const program = new Command().exitOverride();
|
|
||||||
registerStoreCommand(program, paths);
|
|
||||||
return program.parseAsync(['node', 'mosaic', 'store', ...args]);
|
|
||||||
};
|
|
||||||
|
|
||||||
it('registers on the parent program and renders help', () => {
|
|
||||||
const program = new Command().exitOverride();
|
|
||||||
registerStoreCommand(program, paths);
|
|
||||||
const cmd = program.commands.find((c) => c.name() === 'store');
|
|
||||||
expect(cmd).toBeDefined();
|
|
||||||
expect(() => cmd?.helpInformation()).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('add exits nonzero with a typed code for an invalid name', async () => {
|
|
||||||
await parse([
|
|
||||||
'add',
|
|
||||||
'skill',
|
|
||||||
'../../etc',
|
|
||||||
'1.0.0',
|
|
||||||
'--from',
|
|
||||||
createSource('x'),
|
|
||||||
'--by',
|
|
||||||
'op',
|
|
||||||
]);
|
|
||||||
expect(process.exitCode).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('add exits nonzero when the kind is plural', async () => {
|
|
||||||
await parse(['add', 'skills', 'demo', '1.0.0', '--from', createSource('demo'), '--by', 'op']);
|
|
||||||
expect(process.exitCode).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('add succeeds and creates the entry directory', async () => {
|
|
||||||
await parse(['add', 'skill', 'demo', '1.0.0', '--from', createSource('demo'), '--by', 'op']);
|
|
||||||
expect(process.exitCode).toBeUndefined();
|
|
||||||
expect(lstatSync(join(paths.userRoot, 'skills', 'demo', '1.0.0')).isDirectory()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('add requires --by (commander requiredOption)', async () => {
|
|
||||||
await expect(
|
|
||||||
parse(['add', 'skill', 'demo', '1.0.0', '--from', createSource('demo')]),
|
|
||||||
).rejects.toThrow(/--by/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('add exits nonzero on an unmarked target without --reclaim, preserving content', async () => {
|
|
||||||
const unmarked = join(paths.userRoot, 'skills', 'demo', '1.0.0');
|
|
||||||
mkdirSync(unmarked, { recursive: true });
|
|
||||||
writeFileSync(join(unmarked, 'SKILL.md'), '# operator\n');
|
|
||||||
await parse(['add', 'skill', 'demo', '1.0.0', '--from', createSource('demo'), '--by', 'op']);
|
|
||||||
expect(process.exitCode).toBe(1);
|
|
||||||
expect(readFileSync(join(unmarked, 'SKILL.md'), 'utf-8')).toBe('# operator\n');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('add --reclaim replaces the unmarked target and succeeds', async () => {
|
|
||||||
const unmarked = join(paths.userRoot, 'skills', 'demo', '1.0.0');
|
|
||||||
mkdirSync(unmarked, { recursive: true });
|
|
||||||
writeFileSync(join(unmarked, 'SKILL.md'), '# torn\n');
|
|
||||||
await parse([
|
|
||||||
'add',
|
|
||||||
'skill',
|
|
||||||
'demo',
|
|
||||||
'1.0.0',
|
|
||||||
'--from',
|
|
||||||
createSource('demo'),
|
|
||||||
'--by',
|
|
||||||
'op',
|
|
||||||
'--reclaim',
|
|
||||||
]);
|
|
||||||
expect(process.exitCode).toBeUndefined();
|
|
||||||
expect(readFileSync(join(unmarked, 'SKILL.md'), 'utf-8')).toBe('# demo\n');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('list exits 0 on an empty store', async () => {
|
|
||||||
await parse(['list']);
|
|
||||||
expect(process.exitCode).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,544 +0,0 @@
|
|||||||
import {
|
|
||||||
cpSync,
|
|
||||||
existsSync,
|
|
||||||
lstatSync,
|
|
||||||
mkdirSync,
|
|
||||||
readdirSync,
|
|
||||||
readFileSync,
|
|
||||||
rmSync,
|
|
||||||
writeFileSync,
|
|
||||||
type Dirent,
|
|
||||||
type Stats,
|
|
||||||
} from 'node:fs';
|
|
||||||
import { isAbsolute, join, parse, relative, resolve, sep } from 'node:path';
|
|
||||||
import type { Command } from 'commander';
|
|
||||||
import { DEFAULT_MOSAIC_USER_HOME } from '../constants.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `mosaic store` — the vetted user store under `~/.mosaic/{plugins,skills}` (W-F4).
|
|
||||||
*
|
|
||||||
* Two roots with distinct ownership (HARNESS-HOMES design, frozen REV3):
|
|
||||||
* - `~/.config/mosaic/` is the SYSTEM root: update-owned, replaceable wholesale.
|
|
||||||
* - `~/.mosaic/` is the USER root: never touched by installs or updates.
|
|
||||||
*
|
|
||||||
* This module only ever writes under the USER root. The store is the vetting
|
|
||||||
* boundary: content lands here only through an explicit `store add` carrying a
|
|
||||||
* named vetting attribution, and every entry is versioned
|
|
||||||
* (`store/<kind>s/<name>/<version>/`) with a `store-entry.json` marker written
|
|
||||||
* LAST — a version directory without its marker is never a usable entry, and
|
|
||||||
* an unmarked target is REFUSED by default: it may be this tool's own debris
|
|
||||||
* from an interrupted add, or content the operator placed by hand, and the
|
|
||||||
* code cannot tell those apart — so deletion happens only under an explicit
|
|
||||||
* `--reclaim` opt-in, and the result status names what was done.
|
|
||||||
*
|
|
||||||
* Deferred by design (W-F6 and later): activation/symlink-install into agent
|
|
||||||
* homes, `current`-pointer pinning, network acquisition. `add` accepts a local
|
|
||||||
* source path only — no network, no credentials, ever.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type StoreKind = 'plugin' | 'skill';
|
|
||||||
export const STORE_KINDS: readonly StoreKind[] = ['plugin', 'skill'];
|
|
||||||
|
|
||||||
/** On-disk metadata marker; written last so its presence commits an entry. */
|
|
||||||
export const STORE_ENTRY_MARKER = 'store-entry.json';
|
|
||||||
|
|
||||||
export interface StorePaths {
|
|
||||||
/** User data root, e.g. `~/.mosaic`. */
|
|
||||||
userRoot: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StoreEntryMeta {
|
|
||||||
schema: 1;
|
|
||||||
kind: StoreKind;
|
|
||||||
name: string;
|
|
||||||
version: string;
|
|
||||||
/** Absolute source path the content was vetted from, as resolved at add time. */
|
|
||||||
sourcePath: string;
|
|
||||||
/** Operator who vouched for the content — required, non-empty. */
|
|
||||||
vettedBy: string;
|
|
||||||
/** ISO timestamp of the add. */
|
|
||||||
vettedAt: string;
|
|
||||||
/** Free-form vetting notes, if any. */
|
|
||||||
notes?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type StoreAddStatus = 'added' | 'reclaimed-unmarked';
|
|
||||||
|
|
||||||
export interface StoreAddResult {
|
|
||||||
kind: StoreKind;
|
|
||||||
name: string;
|
|
||||||
version: string;
|
|
||||||
status: StoreAddStatus;
|
|
||||||
entryPath: string;
|
|
||||||
sourcePath: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type StoreEntryStatus = 'vetted' | 'incomplete' | 'invalid-metadata' | 'foreign';
|
|
||||||
|
|
||||||
export interface StoreListEntry {
|
|
||||||
kind: StoreKind;
|
|
||||||
name: string;
|
|
||||||
/** Undefined for name-level foreign files (not a directory at all). */
|
|
||||||
version?: string;
|
|
||||||
status: StoreEntryStatus;
|
|
||||||
entryPath: string;
|
|
||||||
meta?: StoreEntryMeta;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SAFE_STORE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
||||||
const SAFE_STORE_VERSION = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/;
|
|
||||||
|
|
||||||
export class StoreError extends Error {
|
|
||||||
public readonly code: string;
|
|
||||||
|
|
||||||
public constructor(code: string, message: string) {
|
|
||||||
super(message);
|
|
||||||
this.name = 'StoreError';
|
|
||||||
this.code = code;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Resolve the user store root while keeping tests injectable. */
|
|
||||||
export function getDefaultStorePaths(): StorePaths {
|
|
||||||
const userRoot = process.env['MOSAIC_USER_HOME'] ?? DEFAULT_MOSAIC_USER_HOME;
|
|
||||||
return { userRoot };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reject a user-supplied name before any filesystem operation.
|
|
||||||
* A store name identifies one directory under `store/<kind>s/`.
|
|
||||||
*/
|
|
||||||
export function validateStoreName(name: string): void {
|
|
||||||
if (
|
|
||||||
name.length === 0 ||
|
|
||||||
name.startsWith('-') ||
|
|
||||||
name.endsWith('.') ||
|
|
||||||
name.includes('..') ||
|
|
||||||
name.includes('/') ||
|
|
||||||
name.includes('\\') ||
|
|
||||||
isAbsolute(name) ||
|
|
||||||
!SAFE_STORE_NAME.test(name)
|
|
||||||
) {
|
|
||||||
throw new StoreError(
|
|
||||||
'STORE_INVALID_NAME',
|
|
||||||
`Invalid store name ${JSON.stringify(name)}: use letters, numbers, dots, underscores, or hyphens; start with a letter or number; and do not use paths, "..", or a leading "-".`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Versions share the name discipline plus `+` (semver build metadata). */
|
|
||||||
export function validateStoreVersion(version: string): void {
|
|
||||||
if (
|
|
||||||
version.length === 0 ||
|
|
||||||
version.startsWith('-') ||
|
|
||||||
version.endsWith('.') ||
|
|
||||||
version.includes('..') ||
|
|
||||||
version.includes('/') ||
|
|
||||||
version.includes('\\') ||
|
|
||||||
isAbsolute(version) ||
|
|
||||||
!SAFE_STORE_VERSION.test(version)
|
|
||||||
) {
|
|
||||||
throw new StoreError(
|
|
||||||
'STORE_INVALID_VERSION',
|
|
||||||
`Invalid version ${JSON.stringify(version)}: use letters, numbers, dots, underscores, hyphens, or plus; start with a letter or number; and do not use paths, "..", or a leading "-".`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function validateStoreKind(kind: string): asserts kind is StoreKind {
|
|
||||||
if (!(STORE_KINDS as readonly string[]).includes(kind)) {
|
|
||||||
throw new StoreError(
|
|
||||||
'STORE_INVALID_KIND',
|
|
||||||
`Invalid store kind ${JSON.stringify(kind)}: expected one of ${STORE_KINDS.map((k) => `"${k}"`).join(', ')}.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateVettedBy(vettedBy: string): void {
|
|
||||||
if (vettedBy.trim().length === 0 || vettedBy.includes('\n') || vettedBy.length > 80) {
|
|
||||||
throw new StoreError(
|
|
||||||
'STORE_INVALID_VETTER',
|
|
||||||
'Invalid --by value: name the operator vouching for this content (single line, at most 80 characters).',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function lstatIfPresent(path: string): Stats | undefined {
|
|
||||||
try {
|
|
||||||
return lstatSync(path);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return undefined;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertNoSymlinkAncestors(path: string): void {
|
|
||||||
const absolute = resolve(path);
|
|
||||||
const pathRoot = parse(absolute).root;
|
|
||||||
let current = pathRoot;
|
|
||||||
|
|
||||||
for (const segment of relative(pathRoot, absolute).split(sep)) {
|
|
||||||
if (segment.length === 0) continue;
|
|
||||||
current = join(current, segment);
|
|
||||||
const entry = lstatIfPresent(current);
|
|
||||||
if (!entry) break;
|
|
||||||
if (entry.isSymbolicLink()) {
|
|
||||||
throw new StoreError(
|
|
||||||
'STORE_SYMLINK_ROOT',
|
|
||||||
`Refusing symlink ancestor at ${current}; the user store root must resolve without symlink traversal.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `plugins` for plugin, `skills` for skill — plural on disk per the layout. */
|
|
||||||
function kindDirName(kind: StoreKind): string {
|
|
||||||
return kind === 'plugin' ? 'plugins' : 'skills';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function storeKindDir(kind: StoreKind, paths: StorePaths = getDefaultStorePaths()): string {
|
|
||||||
return join(paths.userRoot, kindDirName(kind));
|
|
||||||
}
|
|
||||||
|
|
||||||
function entryDir(kind: StoreKind, name: string, version: string, paths: StorePaths): string {
|
|
||||||
return join(storeKindDir(kind, paths), name, version);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isInsideRoot(candidate: string, root: string): boolean {
|
|
||||||
const rel = relative(resolve(root), resolve(candidate));
|
|
||||||
return rel.length > 0 && rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refuse any symlink in the source tree — the vetting boundary copies real
|
|
||||||
* content only, so a vetted entry can never carry a link that escapes it.
|
|
||||||
*
|
|
||||||
* NOTE: known check-then-use window between this walk and the `cpSync` below:
|
|
||||||
* a symlink created concurrently with the add could slip through. Accepted
|
|
||||||
* for a local, operator-run CLI; revisit before any unattended or networked
|
|
||||||
* acquisition path exists.
|
|
||||||
*/
|
|
||||||
function assertSourceTreeHasNoSymlinks(sourcePath: string): void {
|
|
||||||
const stack: string[] = [sourcePath];
|
|
||||||
while (stack.length > 0) {
|
|
||||||
const current = stack.pop()!;
|
|
||||||
for (const dirent of readdirSync(current, { withFileTypes: true })) {
|
|
||||||
const child = join(current, dirent.name);
|
|
||||||
if (dirent.isSymbolicLink()) {
|
|
||||||
throw new StoreError(
|
|
||||||
'STORE_SOURCE_SYMLINK',
|
|
||||||
`Refusing to vet content containing a symlink: ${child}. Resolve or remove symlinks before adding to the store.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (dirent.isDirectory()) stack.push(child);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Vet and add one versioned entry to the user store.
|
|
||||||
*
|
|
||||||
* Copies the source directory (real content, no symlinks) to
|
|
||||||
* `<userRoot>/<kind>s/<name>/<version>/` and writes the `store-entry.json`
|
|
||||||
* marker LAST: a crash mid-copy leaves at most a recoverable partial, never a
|
|
||||||
* half-vetted entry that lists as present.
|
|
||||||
*/
|
|
||||||
export function addStoreEntry(
|
|
||||||
kind: StoreKind,
|
|
||||||
name: string,
|
|
||||||
version: string,
|
|
||||||
sourcePath: string,
|
|
||||||
vettedBy: string,
|
|
||||||
notes: string | undefined,
|
|
||||||
paths: StorePaths = getDefaultStorePaths(),
|
|
||||||
options: { reclaim?: boolean } = {},
|
|
||||||
): StoreAddResult {
|
|
||||||
validateStoreKind(kind);
|
|
||||||
validateStoreName(name);
|
|
||||||
validateStoreVersion(version);
|
|
||||||
validateVettedBy(vettedBy);
|
|
||||||
assertNoSymlinkAncestors(paths.userRoot);
|
|
||||||
|
|
||||||
const resolvedSource = resolve(sourcePath);
|
|
||||||
const source = lstatIfPresent(resolvedSource);
|
|
||||||
if (!source) {
|
|
||||||
throw new StoreError('STORE_SOURCE_MISSING', `Source path does not exist: ${resolvedSource}`);
|
|
||||||
}
|
|
||||||
if (source.isSymbolicLink()) {
|
|
||||||
throw new StoreError(
|
|
||||||
'STORE_SOURCE_SYMLINK',
|
|
||||||
`Refusing to vet a symlink as store content: ${resolvedSource} (points at ${resolve(sourcePath)}). Add the real directory.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!source.isDirectory()) {
|
|
||||||
throw new StoreError(
|
|
||||||
'STORE_SOURCE_NOT_DIR',
|
|
||||||
`Source path is not a directory: ${resolvedSource}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (isInsideRoot(resolvedSource, paths.userRoot)) {
|
|
||||||
throw new StoreError(
|
|
||||||
'STORE_SOURCE_INSIDE_STORE',
|
|
||||||
`Refusing to add store content from inside the store itself: ${resolvedSource}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
assertSourceTreeHasNoSymlinks(resolvedSource);
|
|
||||||
|
|
||||||
const target = entryDir(kind, name, version, paths);
|
|
||||||
const existing = lstatIfPresent(target);
|
|
||||||
let status: StoreAddStatus = 'added';
|
|
||||||
if (existing) {
|
|
||||||
if (existsSync(join(target, STORE_ENTRY_MARKER))) {
|
|
||||||
throw new StoreError(
|
|
||||||
'STORE_ALREADY_PRESENT',
|
|
||||||
`${kind} "${name}" version "${version}" is already present at ${target}; stores are append-only — add a new version instead.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Unmarked target: either this tool's own debris from an interrupted add,
|
|
||||||
// or content the operator placed by hand — indistinguishable on disk. The
|
|
||||||
// USER root's contract is that tooling never destroys operator content,
|
|
||||||
// so deletion requires the explicit --reclaim opt-in (review finding on
|
|
||||||
// c23a71d7: silent rmSync under a benign-sounding status).
|
|
||||||
if (!options.reclaim) {
|
|
||||||
throw new StoreError(
|
|
||||||
'STORE_TARGET_UNMARKED',
|
|
||||||
`Target exists without ${STORE_ENTRY_MARKER}: ${target}. Refusing to delete unmarked content — if this is debris from an interrupted add, re-run with --reclaim to replace it.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
rmSync(target, { recursive: true, force: true });
|
|
||||||
status = 'reclaimed-unmarked';
|
|
||||||
}
|
|
||||||
|
|
||||||
mkdirSync(target, { recursive: true });
|
|
||||||
cpSync(resolvedSource, target, { recursive: true });
|
|
||||||
|
|
||||||
const meta: StoreEntryMeta = {
|
|
||||||
schema: 1,
|
|
||||||
kind,
|
|
||||||
name,
|
|
||||||
version,
|
|
||||||
sourcePath: resolvedSource,
|
|
||||||
vettedBy: vettedBy.trim(),
|
|
||||||
vettedAt: new Date().toISOString(),
|
|
||||||
...(notes === undefined ? {} : { notes }),
|
|
||||||
};
|
|
||||||
writeFileSync(join(target, STORE_ENTRY_MARKER), `${JSON.stringify(meta, null, 2)}\n`);
|
|
||||||
|
|
||||||
return { kind, name, version, status, entryPath: target, sourcePath: resolvedSource };
|
|
||||||
}
|
|
||||||
|
|
||||||
function readEntryMeta(markerPath: string): { meta?: StoreEntryMeta; status: StoreEntryStatus } {
|
|
||||||
let raw: string;
|
|
||||||
try {
|
|
||||||
raw = readFileSync(markerPath, 'utf-8');
|
|
||||||
} catch {
|
|
||||||
return { status: 'invalid-metadata' };
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw) as StoreEntryMeta;
|
|
||||||
if (
|
|
||||||
parsed?.schema === 1 &&
|
|
||||||
(STORE_KINDS as readonly string[]).includes(parsed.kind) &&
|
|
||||||
typeof parsed.name === 'string' &&
|
|
||||||
typeof parsed.version === 'string' &&
|
|
||||||
typeof parsed.vettedBy === 'string' &&
|
|
||||||
typeof parsed.vettedAt === 'string'
|
|
||||||
) {
|
|
||||||
return { meta: parsed, status: 'vetted' };
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// fall through
|
|
||||||
}
|
|
||||||
return { status: 'invalid-metadata' };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enumerate every store entry deterministically (kind, then name, then
|
|
||||||
* version). Version directories without a marker list as `incomplete`; files
|
|
||||||
* where directories were expected list as `foreign` — surfaced, never mutated.
|
|
||||||
*/
|
|
||||||
export function listStoreEntries(
|
|
||||||
paths: StorePaths = getDefaultStorePaths(),
|
|
||||||
filter: { kind?: StoreKind; name?: string } = {},
|
|
||||||
): StoreListEntry[] {
|
|
||||||
if (filter.name !== undefined) validateStoreName(filter.name);
|
|
||||||
assertNoSymlinkAncestors(paths.userRoot);
|
|
||||||
|
|
||||||
const kinds = filter.kind ? [filter.kind] : [...STORE_KINDS];
|
|
||||||
const entries: StoreListEntry[] = [];
|
|
||||||
|
|
||||||
for (const kind of kinds) {
|
|
||||||
const kindRoot = lstatIfPresent(storeKindDir(kind, paths));
|
|
||||||
if (!kindRoot) continue;
|
|
||||||
if (!kindRoot.isDirectory()) {
|
|
||||||
entries.push({
|
|
||||||
kind,
|
|
||||||
name: kindDirName(kind),
|
|
||||||
status: 'foreign',
|
|
||||||
entryPath: storeKindDir(kind, paths),
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const nameDirent of readdirSync(storeKindDir(kind, paths), {
|
|
||||||
withFileTypes: true,
|
|
||||||
}).sort(byName) as Dirent[]) {
|
|
||||||
if (filter.name !== undefined && nameDirent.name !== filter.name) continue;
|
|
||||||
const namePath = join(storeKindDir(kind, paths), nameDirent.name);
|
|
||||||
|
|
||||||
if (!nameDirent.isDirectory()) {
|
|
||||||
entries.push({ kind, name: nameDirent.name, status: 'foreign', entryPath: namePath });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const versionDirents = readdirSync(namePath, { withFileTypes: true }).sort(byName);
|
|
||||||
if (versionDirents.length === 0) {
|
|
||||||
entries.push({ kind, name: nameDirent.name, status: 'incomplete', entryPath: namePath });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
for (const versionDirent of versionDirents) {
|
|
||||||
const versionPath = join(namePath, versionDirent.name);
|
|
||||||
if (!versionDirent.isDirectory()) {
|
|
||||||
entries.push({
|
|
||||||
kind,
|
|
||||||
name: nameDirent.name,
|
|
||||||
version: versionDirent.name,
|
|
||||||
status: 'foreign',
|
|
||||||
entryPath: versionPath,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const markerPath = join(versionPath, STORE_ENTRY_MARKER);
|
|
||||||
if (!existsSync(markerPath)) {
|
|
||||||
entries.push({
|
|
||||||
kind,
|
|
||||||
name: nameDirent.name,
|
|
||||||
version: versionDirent.name,
|
|
||||||
status: 'incomplete',
|
|
||||||
entryPath: versionPath,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const { meta, status } = readEntryMeta(markerPath);
|
|
||||||
entries.push({
|
|
||||||
kind,
|
|
||||||
name: nameDirent.name,
|
|
||||||
version: versionDirent.name,
|
|
||||||
status,
|
|
||||||
entryPath: versionPath,
|
|
||||||
...(meta === undefined ? {} : { meta }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
function byName(a: Dirent, b: Dirent): number {
|
|
||||||
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function reportCommandError(error: unknown): void {
|
|
||||||
if (error instanceof StoreError) {
|
|
||||||
console.error(`store: ${error.code}: ${error.message}`);
|
|
||||||
} else {
|
|
||||||
console.error(error instanceof Error ? error.message : String(error));
|
|
||||||
}
|
|
||||||
process.exitCode = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function displayStoreName(name: string): string {
|
|
||||||
return SAFE_STORE_NAME.test(name) ? name : JSON.stringify(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Register the `mosaic store` command group (W-F4). */
|
|
||||||
export function registerStoreCommand(
|
|
||||||
program: Command,
|
|
||||||
paths: StorePaths = getDefaultStorePaths(),
|
|
||||||
): void {
|
|
||||||
const store = program
|
|
||||||
.command('store')
|
|
||||||
.description('Manage the vetted user store under ~/.mosaic (plugins, skills)')
|
|
||||||
.configureHelp({ sortSubcommands: true });
|
|
||||||
|
|
||||||
store
|
|
||||||
.command('add <kind> <name> <version>')
|
|
||||||
.description(
|
|
||||||
'Vet and add a local plugin/skill directory to the user store (versioned, append-only)',
|
|
||||||
)
|
|
||||||
.requiredOption('--from <path>', 'Local source directory to vet (no network acquisition)')
|
|
||||||
.requiredOption('--by <operator>', 'Name of the operator vouching for this content')
|
|
||||||
.option('--notes <notes>', 'Vetting notes recorded in the entry metadata')
|
|
||||||
.option(
|
|
||||||
'--reclaim',
|
|
||||||
'Replace an existing UNMARKED target directory (e.g. debris from an interrupted add); refuses without this flag',
|
|
||||||
)
|
|
||||||
.action(
|
|
||||||
async (
|
|
||||||
kind: string,
|
|
||||||
name: string,
|
|
||||||
version: string,
|
|
||||||
opts: {
|
|
||||||
from: string;
|
|
||||||
by: string;
|
|
||||||
notes?: string;
|
|
||||||
reclaim: boolean;
|
|
||||||
},
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
const result = addStoreEntry(
|
|
||||||
kind as StoreKind,
|
|
||||||
name,
|
|
||||||
version,
|
|
||||||
opts.from,
|
|
||||||
opts.by,
|
|
||||||
opts.notes,
|
|
||||||
paths,
|
|
||||||
{ reclaim: opts.reclaim },
|
|
||||||
);
|
|
||||||
const suffix =
|
|
||||||
result.status === 'reclaimed-unmarked' ? ' (replaced unmarked directory)' : '';
|
|
||||||
console.log(
|
|
||||||
`${result.kind} ${displayStoreName(result.name)} ${result.version}: added${suffix}`,
|
|
||||||
);
|
|
||||||
console.log(` entry: ${result.entryPath}`);
|
|
||||||
console.log(` vetted by ${opts.by.trim()}`);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
reportCommandError(error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
store
|
|
||||||
.command('list')
|
|
||||||
.description('List store entries with vetting status')
|
|
||||||
.option('--kind <kind>', 'Filter by kind (plugin | skill)')
|
|
||||||
.option('--name <name>', 'Filter by entry name')
|
|
||||||
.action((opts: { kind?: string; name?: string }) => {
|
|
||||||
try {
|
|
||||||
let kind: StoreKind | undefined;
|
|
||||||
if (opts.kind !== undefined) {
|
|
||||||
validateStoreKind(opts.kind);
|
|
||||||
kind = opts.kind;
|
|
||||||
}
|
|
||||||
const entries = listStoreEntries(paths, {
|
|
||||||
...(kind === undefined ? {} : { kind }),
|
|
||||||
...(opts.name === undefined ? {} : { name: opts.name }),
|
|
||||||
});
|
|
||||||
if (entries.length === 0) {
|
|
||||||
console.log('No store entries found.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const entry of entries) {
|
|
||||||
const version = entry.version ?? '-';
|
|
||||||
const vetter = entry.meta?.vettedBy ?? '-';
|
|
||||||
console.log(
|
|
||||||
`${entry.status.padEnd(17)}${entry.kind.padEnd(8)}${displayStoreName(entry.name).padEnd(24)}${version.padEnd(16)}${vetter}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
|
||||||
reportCommandError(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -5,13 +5,6 @@ export const VERSION = '0.0.2';
|
|||||||
|
|
||||||
export const DEFAULT_MOSAIC_HOME = join(homedir(), '.config', 'mosaic');
|
export const DEFAULT_MOSAIC_HOME = join(homedir(), '.config', 'mosaic');
|
||||||
|
|
||||||
/**
|
|
||||||
* USER data root (HARNESS-HOMES two-root split): everything under here is user
|
|
||||||
* content — never replaced or removed by installs, updates, or uninstallers.
|
|
||||||
* Distinct from the SYSTEM root above, which is update-owned.
|
|
||||||
*/
|
|
||||||
export const DEFAULT_MOSAIC_USER_HOME = join(homedir(), '.mosaic');
|
|
||||||
|
|
||||||
export const DEFAULTS = {
|
export const DEFAULTS = {
|
||||||
agentName: 'Assistant',
|
agentName: 'Assistant',
|
||||||
roleDescription: 'execution partner and visibility engine',
|
roleDescription: 'execution partner and visibility engine',
|
||||||
|
|||||||
Reference in New Issue
Block a user