* docs(memhub): define SDK-owned memory architecture
Co-authored-by: Cursor <cursoragent@cursor.com>
* test(memhub): freeze legacy memory authority debt
Co-authored-by: Cursor <cursoragent@cursor.com>
* docs(memhub): define SDK data-plane migration contract
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(memhub): add SDK cutover migration ledger
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(memhub): create verified SDK migration bundles
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(memhub): expose offline migration bundle command
Co-authored-by: Cursor <cursoragent@cursor.com>
* docs(memhub): keep durable write scheduling in Core
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor(memhub): delegate SDK backup to public API
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(memhub): restore complete SDK migration bundles
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor(memhub): route SDK snapshots through bridge
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(memhub): persist SDK cutover state
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(memhub): add SDK-native memory projection
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor(memhub): recover writes from SDK outcomes
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore(memhub): pin SDK authority dev preview
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(memhub): classify legacy and SDK memory inventories
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor(memhub): trust SDK physical delete receipts
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(memhub): execute classified memory migrations
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore(memhub): pin exact migration SDK dev preview
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(memhub): preserve source SDK version in migration bundle
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor(memhub): use one schema-driven legacy migration
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(memhub): migrate Evidence into SDK raw
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(memhub): run resumable offline SDK backfill
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(memhub): expose offline SDK backfill command
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(memhub): expose SDK-native memory APIs
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(web): add SDK-native memory client
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(memhub): migrate recall scopes by memory id
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor(memhub): remove fabricated per-memory harness ACL
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor(web): remove legacy per-memory recall controls
Co-authored-by: Cursor <cursoragent@cursor.com>
* docs(memhub): use SDK source filters for future harness policy
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor(memhub): remove per-memory scope mutation API
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor(web): remove dead recall scope component
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore(memhub): pin raw-search SDK dev preview
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(memhub): search SDK raw through existing API
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(memhub): add honest SDK-native Context v2
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor(memhub): switch library to sdk authority
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor(memhub): cut host context over to sdk v2
Co-authored-by: Cursor <cursoragent@cursor.com>
* docs(memhub): run migration during app startup
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor(memhub): add sdk raw scheduling receipts
Co-authored-by: Cursor <cursoragent@cursor.com>
* Revert "refactor(memhub): add sdk raw scheduling receipts"
This reverts commit d07ae91c93.
* refactor(memhub): persist sdk ids after queued add
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor(memhub): stop governance mirroring after cutover
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor(memhub): fence startup migration and resume effects
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor(memhub): release transient evidence after sdk success
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor(memhub): gate startup authority cutover
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor(memhub): run sdk migration in app loading
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor(memhub): block cutover until product paths switch
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor(memhub): resume sdk staging cleanup on startup
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor(memhub): add sdk-native ask v2
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor(memhub): migrate matters to sdk references
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor(memhub): hydrate matters from sdk references
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor(memhub): switch matter reads to sdk references
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor(memhub): queue sdk matter notes
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor(memhub): discover matters from sdk authority
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor(memhub): retire legacy digest and boundary after cutover
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor(memhub): complete sdk raw and matter cutover
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor(memhub): stop legacy maintenance after cutover
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor(memhub): retire remaining legacy read surfaces
Co-authored-by: Cursor <cursoragent@cursor.com>
* docs(memhub): document sdk matter and raw cutover
Co-authored-by: Cursor <cursoragent@cursor.com>
* test(runtime): allow receipt write under parallel load
Co-authored-by: Cursor <cursoragent@cursor.com>
* docs(memhub): publish sdk authority architecture
Co-authored-by: Cursor <cursoragent@cursor.com>
* test(memhub): repair acceptance gate fixtures
Co-authored-by: Cursor <cursoragent@cursor.com>
* docs(memhub): separate fresh schema from legacy cleanup
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor(memhub): finish SDK authority cutover
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(memhub): close CodeQL exposure paths
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(memhub): hash workspace identities without path expansion
Co-authored-by: Cursor <cursoragent@cursor.com>
* docs(memhub): add app migration acceptance guide
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: alvinfei <alvinfei@tencent.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
627 lines
20 KiB
TypeScript
627 lines
20 KiB
TypeScript
import { readFile } from "node:fs/promises";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { run } from "./cli.js";
|
|
// Aliased: this file already declares a local `COMMANDS` (a list of command
|
|
// names). Importing under the same name silently shadowed it, and the test
|
|
// then searched a list of strings for a `name` property instead of failing.
|
|
import { COMMANDS as COMMAND_DESCRIPTORS } from "./command-catalog.js";
|
|
import { PRODUCT } from "./version.js";
|
|
|
|
const packageVersion = (
|
|
JSON.parse(
|
|
await readFile(new URL("../package.json", import.meta.url), "utf8"),
|
|
) as { version: string }
|
|
).version;
|
|
|
|
const sideEffects = vi.hoisted(() => ({
|
|
runRuntimeCli: vi.fn(() => {
|
|
throw new Error("runtime was executed");
|
|
}),
|
|
ensureRuntimeProcess: vi.fn(() => {
|
|
throw new Error("runtime was executed");
|
|
}),
|
|
stopRuntimeProcess: vi.fn(() => {
|
|
throw new Error("runtime was executed");
|
|
}),
|
|
inspectRuntimeStatus: vi.fn(() => {
|
|
throw new Error("runtime was executed");
|
|
}),
|
|
planLocalInstall: vi.fn(() => {
|
|
throw new Error("runtime was executed");
|
|
}),
|
|
applyLocalInstall: vi.fn(() => {
|
|
throw new Error("runtime was executed");
|
|
}),
|
|
planCliLink: vi.fn(() => {
|
|
throw new Error("runtime was executed");
|
|
}),
|
|
installCliLink: vi.fn(() => {
|
|
throw new Error("runtime was executed");
|
|
}),
|
|
installUserService: vi.fn(() => {
|
|
throw new Error("runtime was executed");
|
|
}),
|
|
uninstallUserService: vi.fn(() => {
|
|
throw new Error("runtime was executed");
|
|
}),
|
|
restartUserService: vi.fn(() => {
|
|
throw new Error("runtime was executed");
|
|
}),
|
|
}));
|
|
|
|
vi.mock("@one-memory/runtime-control", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("@one-memory/runtime-control")>()),
|
|
...sideEffects,
|
|
}));
|
|
|
|
// Without this, "was never called" would silently mean "was called in an
|
|
// earlier test", which is exactly the kind of guard that only looks green.
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
const COMMANDS = [
|
|
"serve",
|
|
"start",
|
|
"stop",
|
|
"restart",
|
|
"status",
|
|
"doctor",
|
|
"logs",
|
|
"init",
|
|
"setup",
|
|
"sources",
|
|
"sync",
|
|
"progress",
|
|
"jobs",
|
|
"models",
|
|
"memories",
|
|
"profile",
|
|
// C6 App BLOCK (25f4ecf): UI write operations that had no CLI equivalent.
|
|
"calls",
|
|
"notes",
|
|
"graph",
|
|
"host",
|
|
"recall",
|
|
"service-plan",
|
|
"service-install",
|
|
"service-uninstall",
|
|
"install",
|
|
"uninstall",
|
|
"upgrade",
|
|
"delete-data",
|
|
"onboarding",
|
|
"help",
|
|
"version",
|
|
// A6 product commands. Appended, not inserted: the catalog is a stable
|
|
// contract, so existing names must not shift position.
|
|
"path",
|
|
"adapter",
|
|
"scan",
|
|
"write",
|
|
"task",
|
|
"backup",
|
|
// `ui` is declared but blocked on a Core session contract; it is listed so
|
|
// `help` documents it and the blocked shape is machine-readable.
|
|
"ui",
|
|
] as const;
|
|
|
|
describe("CLI-22 shared output contract", () => {
|
|
describe("help", () => {
|
|
it.each([["help"], ["--help"], ["-h"]])(
|
|
"%s lists every command on stdout and exits 0",
|
|
async (flag) => {
|
|
const streams = captureStreams();
|
|
try {
|
|
expect(await run([flag], {})).toBe(0);
|
|
} finally {
|
|
streams.restore();
|
|
}
|
|
for (const command of COMMANDS) {
|
|
expect(streams.stdout()).toContain(command);
|
|
}
|
|
expect(streams.stderr()).toBe("");
|
|
},
|
|
);
|
|
|
|
it.each(COMMANDS)(
|
|
"documents %s the same way through `help <command>` and `<command> --help`",
|
|
async (command) => {
|
|
const viaHelp = captureStreams();
|
|
try {
|
|
expect(await run(["help", command], {})).toBe(0);
|
|
} finally {
|
|
viaHelp.restore();
|
|
}
|
|
const viaFlag = captureStreams();
|
|
try {
|
|
expect(await run([command, "--help"], {})).toBe(0);
|
|
} finally {
|
|
viaFlag.restore();
|
|
}
|
|
expect(viaHelp.stdout()).toContain(`${PRODUCT} ${command}`);
|
|
expect(viaHelp.stdout()).toBe(viaFlag.stdout());
|
|
expect(viaHelp.stderr()).toBe("");
|
|
expect(viaFlag.stderr()).toBe("");
|
|
},
|
|
);
|
|
|
|
it("never executes the documented command", async () => {
|
|
for (const command of COMMANDS) {
|
|
for (const argv of [
|
|
["help", command],
|
|
[command, "--help"],
|
|
]) {
|
|
const streams = captureStreams();
|
|
try {
|
|
expect(await run(argv, {})).toBe(0);
|
|
} finally {
|
|
streams.restore();
|
|
}
|
|
}
|
|
}
|
|
for (const effect of Object.values(sideEffects)) {
|
|
expect(effect).not.toHaveBeenCalled();
|
|
}
|
|
});
|
|
|
|
it("emits a machine-readable catalog with stable exit codes", async () => {
|
|
const streams = captureStreams();
|
|
try {
|
|
expect(await run(["help", "--json"], {})).toBe(0);
|
|
} finally {
|
|
streams.restore();
|
|
}
|
|
const catalog = JSON.parse(streams.stdout());
|
|
expect(catalog.schema_version).toBe(1);
|
|
expect(catalog.product).toBe(PRODUCT);
|
|
expect(catalog.cli_version).toBe(packageVersion);
|
|
expect(
|
|
catalog.commands.map((entry: { name: string }) => entry.name),
|
|
).toEqual([...COMMANDS]);
|
|
for (const entry of catalog.commands) {
|
|
expect(entry.summary.length).toBeGreaterThan(0);
|
|
expect(entry.usage.startsWith(`${PRODUCT} ${entry.name}`)).toBe(true);
|
|
}
|
|
expect(
|
|
catalog.exit_codes.map((entry: { code: number }) => entry.code),
|
|
).toEqual([0, 1, 2, 3, 4]);
|
|
});
|
|
|
|
it("emits a machine-readable entry for a single command", async () => {
|
|
const streams = captureStreams();
|
|
try {
|
|
expect(await run(["help", "status", "--json"], {})).toBe(0);
|
|
} finally {
|
|
streams.restore();
|
|
}
|
|
const payload = JSON.parse(streams.stdout());
|
|
expect(payload.command.name).toBe("status");
|
|
expect(payload.command.usage).toContain("--json");
|
|
expect(payload.commands).toBeUndefined();
|
|
});
|
|
|
|
it("documents the destructive restore confirmation explicitly", async () => {
|
|
const streams = captureStreams();
|
|
try {
|
|
expect(await run(["help", "backup"], {})).toBe(0);
|
|
} finally {
|
|
streams.restore();
|
|
}
|
|
expect(streams.stdout()).toContain(
|
|
"memhub backup restore --source <path> --yes --confirm <path>",
|
|
);
|
|
expect(streams.stdout()).toContain("逐字重复 --source");
|
|
});
|
|
|
|
it("does not attach restore's confirmation flag to path", async () => {
|
|
const streams = captureStreams();
|
|
try {
|
|
expect(await run(["help", "path"], {})).toBe(0);
|
|
} finally {
|
|
streams.restore();
|
|
}
|
|
expect(streams.stdout()).not.toContain("--confirm <path>");
|
|
});
|
|
});
|
|
|
|
describe("version", () => {
|
|
it.each([["version"], ["--version"], ["-V"]])(
|
|
"%s reports the packaged CLI version on stdout",
|
|
async (flag) => {
|
|
const streams = captureStreams();
|
|
try {
|
|
expect(await run([flag], {})).toBe(0);
|
|
} finally {
|
|
streams.restore();
|
|
}
|
|
expect(streams.stdout()).toBe(`${PRODUCT} ${packageVersion}\n`);
|
|
expect(streams.stderr()).toBe("");
|
|
},
|
|
);
|
|
|
|
it("reports the same version in machine-readable form", async () => {
|
|
const streams = captureStreams();
|
|
try {
|
|
expect(await run(["version", "--json"], {})).toBe(0);
|
|
} finally {
|
|
streams.restore();
|
|
}
|
|
expect(JSON.parse(streams.stdout())).toEqual({
|
|
schema_version: 1,
|
|
product: PRODUCT,
|
|
cli_version: packageVersion,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("argument validation", () => {
|
|
it("fails unknown commands without printing data or running anything", async () => {
|
|
for (const argv of [
|
|
["unknown"],
|
|
["unknown", "--help"],
|
|
["--typo"],
|
|
["permission", "status"],
|
|
["permission", "allow"],
|
|
["permission", "deny"],
|
|
]) {
|
|
const streams = captureStreams();
|
|
try {
|
|
expect(await run(argv, {})).toBe(2);
|
|
} finally {
|
|
streams.restore();
|
|
}
|
|
expect(streams.stdout()).toBe("");
|
|
expect(streams.stderr()).toContain(`${PRODUCT} help`);
|
|
}
|
|
for (const effect of Object.values(sideEffects)) {
|
|
expect(effect).not.toHaveBeenCalled();
|
|
}
|
|
});
|
|
|
|
it("answers a rejected invocation with that command's own usage", async () => {
|
|
const cases: Array<[readonly string[], string]> = [
|
|
[["status", "--typo"], `${PRODUCT} status`],
|
|
[["start", "extra"], `${PRODUCT} start`],
|
|
// `--json` is now a valid flag for stop (stable JSON on every declared
|
|
// command); an unknown flag is still rejected.
|
|
[["stop", "--typo"], `${PRODUCT} stop`],
|
|
[["service-plan", "--home", "/tmp/home"], `${PRODUCT} service-plan`],
|
|
[["install", "--source"], `${PRODUCT} install`],
|
|
[
|
|
[
|
|
"install",
|
|
"--source",
|
|
"/tmp/dist",
|
|
"--release-manifest-url",
|
|
"https://downloads.example.test/release.json",
|
|
],
|
|
`${PRODUCT} install`,
|
|
],
|
|
[["upgrade", "--yes", "--yes"], `${PRODUCT} upgrade`],
|
|
[["version", "extra"], `${PRODUCT} version`],
|
|
[
|
|
["sync", "apply", "--plan-file", "/tmp/plan", "--confirm"],
|
|
`${PRODUCT} sync`,
|
|
],
|
|
[
|
|
["sync", "receipt", "123e4567-e89b-12d3-a456-426614174000"],
|
|
`${PRODUCT} sync`,
|
|
],
|
|
[["sync", "pause", "--source"], `${PRODUCT} sync`],
|
|
];
|
|
for (const [argv, expected] of cases) {
|
|
const streams = captureStreams();
|
|
try {
|
|
expect(await run(argv, {})).toBe(2);
|
|
} finally {
|
|
streams.restore();
|
|
}
|
|
expect(streams.stdout()).toBe("");
|
|
expect(streams.stderr()).toContain(expected);
|
|
}
|
|
});
|
|
|
|
it("reports rejected invocations as machine-readable errors on stderr", async () => {
|
|
const streams = captureStreams();
|
|
try {
|
|
expect(await run(["status", "--typo", "--json"], {})).toBe(2);
|
|
} finally {
|
|
streams.restore();
|
|
}
|
|
expect(streams.stdout()).toBe("");
|
|
const error = JSON.parse(streams.stderr());
|
|
expect(error).toMatchObject({
|
|
schema_version: 1,
|
|
status: "usage_error",
|
|
command: "status",
|
|
exit_code: 2,
|
|
});
|
|
expect(error.next_step.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
// Main's ARGUMENT-REVIEW (2026-09-19) reproduced both gaps below against the
|
|
// real run(): serve reached the Runtime with junk arguments, and a help token
|
|
// anywhere won, including where an option value was missing.
|
|
describe("strict argument and help parsing", () => {
|
|
it("rejects invalid serve arguments before the Runtime is reachable", async () => {
|
|
for (const argv of [
|
|
["serve", "--typo"],
|
|
["serve", "extra"],
|
|
["serve", "--json"],
|
|
["serve", "--json", "--json"],
|
|
["serve", "--help", "extra"],
|
|
["serve", "--install-root", "/tmp/install"],
|
|
]) {
|
|
const streams = captureStreams();
|
|
try {
|
|
expect(await run(argv, {})).toBe(2);
|
|
} finally {
|
|
streams.restore();
|
|
}
|
|
expect(streams.stdout()).toBe("");
|
|
expect(streams.stderr()).toContain(`${PRODUCT} serve`);
|
|
}
|
|
for (const effect of Object.values(sideEffects)) {
|
|
expect(effect).not.toHaveBeenCalled();
|
|
}
|
|
});
|
|
|
|
it("still runs a bare serve and still explains serve --help", async () => {
|
|
// Positive control: without it, the rejection test above proves nothing.
|
|
sideEffects.runRuntimeCli.mockImplementationOnce(() => 0 as never);
|
|
const served = captureStreams();
|
|
try {
|
|
expect(await run(["serve"], {})).toBe(0);
|
|
} finally {
|
|
served.restore();
|
|
}
|
|
expect(sideEffects.runRuntimeCli).toHaveBeenCalledWith(["serve"], {});
|
|
|
|
const helped = captureStreams();
|
|
try {
|
|
expect(await run(["serve", "--help"], {})).toBe(0);
|
|
} finally {
|
|
helped.restore();
|
|
}
|
|
expect(helped.stdout()).toContain(`${PRODUCT} serve`);
|
|
expect(sideEffects.runRuntimeCli).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("refuses a help token standing in for a missing option value", async () => {
|
|
for (const argv of [
|
|
["service-plan", "--install-root", "/tmp/install", "--uid", "--help"],
|
|
["service-plan", "--install-root", "--help"],
|
|
["service-plan", "--install-root", "-h"],
|
|
["install", "--source", "--help"],
|
|
["memories", "list", "--query", "--help"],
|
|
]) {
|
|
const streams = captureStreams();
|
|
try {
|
|
expect(await run(argv, {})).toBe(2);
|
|
} finally {
|
|
streams.restore();
|
|
}
|
|
expect(streams.stdout()).toBe("");
|
|
}
|
|
for (const effect of Object.values(sideEffects)) {
|
|
expect(effect).not.toHaveBeenCalled();
|
|
}
|
|
});
|
|
|
|
it("does not let a help token mask an otherwise invalid invocation", async () => {
|
|
for (const argv of [
|
|
["status", "--typo", "--help"],
|
|
["status", "--help", "--typo"],
|
|
["version", "--json", "--json", "--help"],
|
|
["start", "extra", "-h"],
|
|
["help", "install", "--help"],
|
|
["--help", "install", "extra"],
|
|
]) {
|
|
const streams = captureStreams();
|
|
try {
|
|
expect(await run(argv, {})).toBe(2);
|
|
} finally {
|
|
streams.restore();
|
|
}
|
|
expect(streams.stdout()).toBe("");
|
|
}
|
|
for (const effect of Object.values(sideEffects)) {
|
|
expect(effect).not.toHaveBeenCalled();
|
|
}
|
|
});
|
|
|
|
it("keeps the documented help forms working", async () => {
|
|
for (const argv of [
|
|
["--help"],
|
|
["-h"],
|
|
["help"],
|
|
["help", "install"],
|
|
["help", "--json"],
|
|
["install", "--help"],
|
|
["install", "--help", "--json"],
|
|
["install", "--json", "--help"],
|
|
["memories", "-h"],
|
|
]) {
|
|
const streams = captureStreams();
|
|
try {
|
|
expect(await run(argv, {})).toBe(0);
|
|
} finally {
|
|
streams.restore();
|
|
}
|
|
expect(streams.stdout().length).toBeGreaterThan(0);
|
|
expect(streams.stderr()).toBe("");
|
|
}
|
|
for (const effect of Object.values(sideEffects)) {
|
|
expect(effect).not.toHaveBeenCalled();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("agent-safe output", () => {
|
|
it("keeps colors and animation out of every machine-readable payload", async () => {
|
|
for (const argv of [
|
|
["help", "--json"],
|
|
["help", "install", "--json"],
|
|
["version", "--json"],
|
|
["status", "--typo", "--json"],
|
|
]) {
|
|
const streams = captureStreams();
|
|
try {
|
|
await run(argv, {});
|
|
} finally {
|
|
streams.restore();
|
|
}
|
|
const emitted = `${streams.stdout()}${streams.stderr()}`;
|
|
expect(emitted.includes("\u001B")).toBe(false);
|
|
expect(emitted.includes("\r")).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("never reads stdin, so a piped or non-TTY invocation cannot hang", async () => {
|
|
const spies = [
|
|
vi.spyOn(process.stdin, "read"),
|
|
vi.spyOn(process.stdin, "resume"),
|
|
vi.spyOn(process.stdin, "on"),
|
|
vi.spyOn(process.stdin, "once"),
|
|
vi.spyOn(process.stdin, "setEncoding"),
|
|
];
|
|
let touched = 0;
|
|
try {
|
|
for (const argv of [
|
|
["help"],
|
|
["version"],
|
|
["help", "install"],
|
|
["unknown"],
|
|
["status", "--typo"],
|
|
]) {
|
|
const streams = captureStreams();
|
|
try {
|
|
await run(argv, {});
|
|
} finally {
|
|
streams.restore();
|
|
}
|
|
}
|
|
touched = spies.reduce(
|
|
(total, spy) => total + spy.mock.calls.length,
|
|
0,
|
|
);
|
|
} finally {
|
|
for (const spy of spies) spy.mockRestore();
|
|
}
|
|
expect(touched).toBe(0);
|
|
});
|
|
|
|
it("keeps credentials out of help, version and diagnostics", async () => {
|
|
const environment = {
|
|
ONE_MEMORY_API_TOKEN: "secret-token-value",
|
|
ONE_MEMORY_DATA_DIR: "relative",
|
|
};
|
|
for (const argv of [
|
|
["help"],
|
|
["help", "--json"],
|
|
["version"],
|
|
["service-plan", "--install-root", "/tmp/install"],
|
|
["install", "--data-dir", "relative"],
|
|
["unknown"],
|
|
]) {
|
|
const streams = captureStreams();
|
|
try {
|
|
await run(argv, environment);
|
|
} finally {
|
|
streams.restore();
|
|
}
|
|
expect(`${streams.stdout()}${streams.stderr()}`).not.toContain(
|
|
"secret-token-value",
|
|
);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("documented commands", () => {
|
|
const accepted: Record<string, string[]> = {
|
|
models: [
|
|
"list",
|
|
"show",
|
|
"plan",
|
|
"configure",
|
|
"receipt",
|
|
"probe-plan",
|
|
"probe",
|
|
"probe-receipt",
|
|
"remove",
|
|
],
|
|
// Source processing is one Freeze/Thaw switch under `sync`; the obsolete
|
|
// independent read/write `sources access` command must not return here.
|
|
sources: ["list", "import", "receipt"],
|
|
jobs: ["list", "show", "start", "<pause|resume|cancel>"],
|
|
// `sync <pause|resume>` is documented as one alternation, so it is
|
|
// checked as an entry rather than as two literal subcommands.
|
|
sync: ["status", "<pause|resume>"],
|
|
memories: ["list", "show", "update", "delete"],
|
|
host: ["list", "status", "install", "uninstall", "sync"],
|
|
};
|
|
|
|
// `memhub models remove` parsed and ran for months while `models --help`
|
|
// never mentioned it, so nobody could discover it and no test noticed. The
|
|
// usage string is the only surface users and Agents read, so it has to stay
|
|
// in step with the parser.
|
|
// A subcommand the parser accepts but the usage text omits is invisible:
|
|
it("lists every subcommand the parser accepts in that command's usage", () => {
|
|
for (const [name, subcommands] of Object.entries(accepted)) {
|
|
const descriptor = COMMAND_DESCRIPTORS.find(
|
|
(entry) => entry.name === name,
|
|
);
|
|
expect(descriptor, `${name} is not in the catalog`).toBeDefined();
|
|
// Both surfaces count: commands may document plan/apply variants under
|
|
// `alternative_usage`, printed directly below the primary usage line.
|
|
const documented = `${descriptor?.usage ?? ""}\n${descriptor?.alternative_usage ?? ""}`;
|
|
for (const subcommand of subcommands) {
|
|
expect(
|
|
documented.includes(`${PRODUCT} ${name} ${subcommand}`),
|
|
`${name} ${subcommand} is parsed but never documented`,
|
|
).toBe(true);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("gives every command in the catalog a usage line of its own", () => {
|
|
// The other direction of the same guard: a command could satisfy the
|
|
// checks above by having no usage text at all, which is what "invisible"
|
|
// looks like from the inside. Each catalog entry must start with its own
|
|
// name. Length is not asserted: `serve`, `start`, `stop` and `restart`
|
|
// take no arguments, so their usage is legitimately just the invocation.
|
|
for (const descriptor of COMMAND_DESCRIPTORS) {
|
|
expect(
|
|
descriptor.usage.startsWith(`${PRODUCT} ${descriptor.name}`),
|
|
`${descriptor.name} usage does not start with its own invocation`,
|
|
).toBe(true);
|
|
}
|
|
});
|
|
});
|
|
|
|
function captureStreams() {
|
|
let out = "";
|
|
let error = "";
|
|
const originalOut = process.stdout.write;
|
|
const originalError = process.stderr.write;
|
|
process.stdout.write = ((chunk: string) => {
|
|
out += chunk;
|
|
return true;
|
|
}) as typeof process.stdout.write;
|
|
process.stderr.write = ((chunk: string) => {
|
|
error += chunk;
|
|
return true;
|
|
}) as typeof process.stderr.write;
|
|
return {
|
|
stdout: () => out,
|
|
stderr: () => error,
|
|
restore: () => {
|
|
process.stdout.write = originalOut;
|
|
process.stderr.write = originalError;
|
|
},
|
|
};
|
|
}
|