Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 73 additions & 1 deletion cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,79 @@ Release operators use the
[functional-alpha readiness contract](release/ALPHA_READINESS.md) to distinguish
candidate, promotion, and post-publication authority.

For the candidate service-account connection, see [Connect the CLI to staging](../docs/staging-account.md). This does not enable registry publishing or hosted execution.
For the candidate service-account connection, see [Connect the CLI to staging](../docs/staging-account.md). The candidate registry commands below reuse that account connection; they do not enable hosted execution. Source implementation and hermetic fixtures do not establish deployment, release availability, or live qualification.

## Registry package commands (candidate)

Package commands move bounded, data-only files through the selected OpenProse
account service. Production is the default. Select staging persistently when
working with its separate credentials, then inspect the selection:

```sh
prose cli environment use staging
prose cli environment show
prose cli auth login
prose cli package publish ./hello.md --organization example --name hello --version 1.0.0
prose cli package list example --json
prose cli package fetch example/[email protected] --output-dir ./hello-copy
prose cli package withdraw example/[email protected]
prose cli environment reset
```

A single file uses its basename as the default export and has no dependencies.
Publishing is private unless `--public` is explicit. `list` returns one page of
public receipts, so the private `hello` version above will not appear there.
It returns an optional `nextCursor`; pass that exact value back with
`--cursor`. The cursor is opaque, ASCII, and at most 210 characters. `withdraw`
removes discovery only; an authorized pinned fetch remains available. Versions
are exact SemVer strings, including build metadata. Existing versions cannot be
overwritten, and the client never retries a publication automatically.

For a directory, create `prose-package.json` with precisely the files to include:

```json
{
"schema": "prose-package-directory-v1",
"files": ["README.md", "docs/guide.md"],
"exports": {"default": "README.md", "guide": "docs/guide.md"},
"dependencies": {}
}
```

Then publish that directory with the same identity flags. The CLI reads only the
manifest and listed regular files; it never scans or uploads unlisted files.
Duplicate manifest keys, symlinks and symlink ancestors, unsafe or sensitive
included paths, and invalid metadata are rejected. Limits are 128 files,
256 KiB per file, 1 MiB total decoded content, 64 exports, 64 dependencies, and
2 MiB per service payload. Dependencies are exact hash-pinned metadata and are
neither downloaded nor executed. File contents are not scanned for secrets.
See the [package byte format](shared/fixtures/registry/FORMAT.md) for exact path,
encoding, hash, and receipt rules.

Fetch obtains a receipt and canonical artifact from the same selected service,
then verifies artifact and file hashes, manifest, inventory, and canonical bytes
before writing. Supply `--sha256 <64-lowercase-hex-digest>` to require a known
artifact identity. The output directory must be fresh, its parent must exist,
and its ancestors must not be symlinks. Existing destinations are never replaced.
The receipt is written as `.prose-package-receipt.json`.

Rust installs with a no-replace directory operation on supported platforms. Bun
reserves the destination exclusively and writes verified files, then the receipt;
that directory is visible while being populated. A crash can leave an incomplete
directory without a receipt. There is no implicit resume or overwrite: choose a
fresh destination, or inspect and remove the incomplete directory yourself.
Cleanup preserves content whose ownership no longer matches the operation.
These checks do not sandbox another process running with the same filesystem
privileges. Native platform qualification remains separate from fixture results.

All four commands accept trailing `--json` or global `--output json`. Reports
include the selected environment; staging human output is labeled. Use global
`--service-environment production|staging` before `cli` for a one-command override.
Only user configuration can persist the selection. Production and staging use
separate OS credentials and `OPENPROSE_API_KEY` / `OPENPROSE_STAGING_API_KEY`
respectively; switching never copies credentials. Public reads can be anonymous
when no credential is available, but malformed selected credentials fail closed.
Publish and withdraw require a credential. No command starts a model or harness.

## First five minutes

Expand Down
6 changes: 4 additions & 2 deletions cli/bun/src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { runPackageCommand } from "./core/package-registry";
import { runServiceAccount } from "./core/service-account";
import { runWeaveHost, writeHostBytes, stopHostOutput } from "./core/weave-host";
import { PUBLISHED_KERNEL_STARTUP } from "./core/build";
Expand Down Expand Up @@ -91,9 +92,9 @@ export async function runCli(args: readonly string[], dependencies: CliDependenc
const parsed = parseEntrypoint(args);
if (parsed.global.serviceEnvironment !== undefined) {
mode = parsed.kind === "operation" && parsed.json ? "json" : parsed.global.output ?? "human";
if (parsed.kind !== "operation" || !["auth-status", "auth-login", "auth-logout", "org-list"].includes(parsed.operation) || Object.keys(parsed.global).some((key) => !["serviceEnvironment", "output", "color", "verbose"].includes(key))) throw failure("INVOCATION_INVALID");
if (parsed.kind !== "operation" || !["auth-status", "auth-login", "auth-logout", "org-list", "package"].includes(parsed.operation) || Object.keys(parsed.global).some((key) => !["serviceEnvironment", "output", "color", "verbose"].includes(key))) throw failure("INVOCATION_INVALID");
}
if (parsed.kind === "operation" && (["auth-status", "auth-login", "auth-logout", "org-list"].includes(parsed.operation) || parsed.operation.startsWith("environment-"))) {
if (parsed.kind === "operation" && (["auth-status", "auth-login", "auth-logout", "org-list", "package"].includes(parsed.operation) || parsed.operation.startsWith("environment-"))) {
mode = parsed.json ? "json" : parsed.global.output ?? "human";
if (Object.keys(parsed.global).some((key) => !["serviceEnvironment", "output", "color", "verbose"].includes(key))) throw failure("INVOCATION_INVALID");
let selected = await resolveServiceEnvironment(dependencies);
Expand All @@ -106,6 +107,7 @@ export async function runCli(args: readonly string[], dependencies: CliDependenc
dependencies.writeStdout(mode === "human" ? `OpenProse ${selected.environment} environment (${selected.source})\n` : jsonLine(report));
return 0;
}
if (parsed.operation === "package") return await runPackageCommand(parsed.packageCommand!, mode, dependencies, parsed.global.serviceEnvironment ?? selected.environment);
return await runServiceAccount(parsed.operation, mode, dependencies, parsed.global.serviceEnvironment ?? selected.environment);
}
if (parsed.kind === "weave") return await runWeaveHost(parsed.argv, parsed.global, dependencies);
Expand Down
8 changes: 8 additions & 0 deletions cli/bun/src/core/args.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { parsePackageCommand } from "./package-args";
import { invocationFailure } from "./errors";
import type { GlobalFlags, OutputMode, ParsedEntrypoint } from "./types";

Expand Down Expand Up @@ -126,6 +127,7 @@ function parseOperation(global: GlobalFlags, args: readonly string[]): ParsedEnt
withoutJson.pop();
json = true;
}
if (withoutJson[0] === "package") return { kind: "operation", global, operation: "package", json, packageCommand: parsePackageCommand(withoutJson.slice(1)) };
const key = withoutJson.join(" ");
if (withoutJson.length === 3 && withoutJson[0] === "cleanup" && withoutJson[1] === "prime") {
if (json) invalid("Prime cleanup uses the global `--output json` option before `cli`.");
Expand Down Expand Up @@ -195,6 +197,11 @@ function knownRunnerHelpPath(args: readonly string[]): boolean {
"environment reset --help",
"environment use staging --help",
"environment use production --help",
"package --help",
"package publish --help",
"package fetch --help",
"package list --help",
"package withdraw --help",
"org --help",
"org list --help",
"auth --help",
Expand All @@ -205,6 +212,7 @@ function knownRunnerHelpPath(args: readonly string[]): boolean {
return args.length === 4
&& (
(args[0] === "harness" && args[1] === "use")
|| (args[0] === "package" && ["publish", "fetch", "list", "withdraw"].includes(args[1]!))
|| (args[0] === "cleanup" && args[1] === "prime")
)
&& args[2]!.length > 0
Expand Down
40 changes: 40 additions & 0 deletions cli/bun/src/core/package-args.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { invocationFailure } from "./errors";
export interface PackageCommand {
operation: "publish" | "fetch" | "list" | "withdraw";
input: string;
organization?: string;
name?: string;
version?: string;
public?: boolean;
outputDir?: string;
sha256?: string;
cursor?: string;
}
export function parsePackageCommand(args: readonly string[]): PackageCommand {
const invalid = (): never => { throw invocationFailure("Invalid package command or options."); };
const operation = args[0];
if (operation !== "publish" && operation !== "fetch" && operation !== "list" && operation !== "withdraw") return invalid();
const input = args[1];
if (input === undefined || input.length === 0 || input.startsWith("--")) return invalid();
const command: PackageCommand = { operation, input };
const allowed = operation === "publish" ? ["organization", "name", "version", "public"] : operation === "fetch" ? ["output-dir", "sha256"] : operation === "list" ? ["cursor"] : [];
const seen = new Set<string>();
for (let index = 2; index < args.length; index++) {
const raw = args[index]!;
const equals = raw.indexOf("=");
const option = (equals < 0 ? raw : raw.slice(0, equals)).slice(2);
if (!raw.startsWith("--") || !allowed.includes(option) || seen.has(option)) return invalid();
seen.add(option);
if (option === "public") {
if (equals >= 0) return invalid();
command.public = true;
continue;
}
const value = equals < 0 ? args[++index] : raw.slice(equals + 1);
if (value === undefined || value.length === 0 || value.startsWith("--")) return invalid();
Object.assign(command, { [option === "output-dir" ? "outputDir" : option]: value });
}
if (operation === "publish" && (!command.organization || !command.name || !command.version)) return invalid();
if (operation === "fetch" && !command.outputDir) return invalid();
return command;
}
162 changes: 162 additions & 0 deletions cli/bun/src/core/package-files.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { constants } from "node:fs";
import { lstat, mkdir, mkdtemp, open, rmdir, unlink, type FileHandle } from "node:fs/promises";
import { basename, dirname, join, parse, resolve } from "node:path";
import { closed, PACKAGE_LIMITS, packagePath, parsePackageJSON, preparePackage, canonicalPackageJSON, type PreparedPackage, type PackageReceipt, invalidPackage } from "./package-format";
import type { PackageCommand } from "./package-args";

async function safeAncestors(path: string): Promise<void> {
const absolute = resolve(path), root = parse(absolute).root;
let current = root;
for (const segment of absolute.slice(root.length).split("/").filter(Boolean)) {
current = join(current, segment);
const info = await lstat(current);
if (!info.isDirectory() || info.isSymbolicLink()) invalidPackage();
}
}
async function boundedRegularFile(path: string, limit: number): Promise<Uint8Array> {
await safeAncestors(dirname(path));
const before = await lstat(path);
if (!before.isFile() || before.isSymbolicLink() || before.size > limit) return invalidPackage();
let handle: FileHandle | undefined;
try {
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
const opened = await handle.stat();
if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino || opened.size > limit) return invalidPackage();
const buffer = Buffer.alloc(limit + 1);
let offset = 0;
while (offset < buffer.length) {
const read = await handle.read(buffer, offset, buffer.length - offset, offset);
if (!read.bytesRead) break;
offset += read.bytesRead;
}
const after = await handle.stat();
if (offset > limit || after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs) return invalidPackage();
await safeAncestors(dirname(path));
return buffer.subarray(0, offset);
} finally { await handle?.close(); }
}
// Directory manifests are authored locally: unlike network JSON, duplicate
// member names must never silently replace the user's explicit file selection.
export function parseDirectoryManifest(bytes: Uint8Array): unknown {
const parsed = parsePackageJSON(bytes);
const source = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
let index = 0;
const whitespace = () => { while (/\s/u.test(source[index] ?? "") && index < source.length) index++; };
const string = (): string => {
const start = index++;
while (index < source.length) {
const character = source[index++];
if (character === "\\") index++;
else if (character === '\"') return JSON.parse(source.slice(start, index)) as string;
}
return invalidPackage();
};
const value = (depth: number): void => {
if (depth > 128) invalidPackage();
whitespace();
if (source[index] === '\"') { string(); return; }
if (source[index] === "{") {
index++; whitespace();
const names = new Set<string>();
if (source[index] === "}") { index++; return; }
for (;;) {
whitespace();
const name = string();
if (names.has(name)) invalidPackage();
names.add(name); whitespace(); index++;
value(depth + 1); whitespace();
if (source[index++] === "}") return;
}
}
if (source[index] === "[") {
index++; whitespace();
if (source[index] === "]") { index++; return; }
for (;;) { value(depth + 1); whitespace(); if (source[index++] === "]") return; }
}
while (index < source.length && !/[\s,}\]]/u.test(source[index]!)) index++;
};
value(0);
return parsed;
}
export async function prepareSource(command: PackageCommand, cwd: string): Promise<PreparedPackage> {
const source = resolve(cwd, command.input);
await safeAncestors(dirname(source));
const info = await lstat(source);
if (info.isSymbolicLink()) return invalidPackage();
let paths: string[], exports: unknown, dependencies: unknown;
if (info.isFile()) {
paths = [packagePath(basename(source))]; exports = { default: paths[0] }; dependencies = {};
} else if (info.isDirectory()) {
const manifest = closed(parseDirectoryManifest(await boundedRegularFile(join(source, "prose-package.json"), PACKAGE_LIMITS.request)), ["schema", "files", "exports", "dependencies"]);
if (manifest.schema !== "prose-package-directory-v1" || !Array.isArray(manifest.files) || !manifest.files.length || manifest.files.length > PACKAGE_LIMITS.files) return invalidPackage();
paths = manifest.files.map(packagePath); exports = manifest.exports; dependencies = manifest.dependencies;
} else return invalidPackage();
const files = [];
let total = 0;
for (const path of paths) {
const bytes = await boundedRegularFile(info.isFile() ? source : join(source, path), PACKAGE_LIMITS.file);
total += bytes.length;
if (total > PACKAGE_LIMITS.total) return invalidPackage();
files.push({ path, encoding: "base64", content: Buffer.from(bytes).toString("base64") });
}
return preparePackage({ schema: "prose-package-v1", manifest: { organization: command.organization, package: command.name, version: command.version, visibility: command.public ? "public" : "private", exports, dependencies }, files });
}
interface Owned { path: string; dev: number; ino: number; directory: boolean }
async function identity(path: string, directory: boolean): Promise<Owned> {
const info = await lstat(path);
if (info.isSymbolicLink() || (directory ? !info.isDirectory() : !info.isFile())) return invalidPackage();
return { path, dev: info.dev, ino: info.ino, directory };
}
async function stillOwned(owned: Owned): Promise<boolean> {
try { const info = await lstat(owned.path); return info.dev === owned.dev && info.ino === owned.ino && !info.isSymbolicLink(); } catch { return false; }
}
async function cleanupOwned(entries: Owned[]): Promise<void> {
for (const entry of [...entries].reverse()) {
const ancestors = entries.filter(other => other.directory && entry.path.startsWith(`${other.path}/`));
if (!(await stillOwned(entry)) || (await Promise.all(ancestors.map(stillOwned))).some(owned => !owned)) continue;
try { if (entry.directory) await rmdir(entry.path); else await unlink(entry.path); } catch { /* Preserve concurrent additions and substitutions. */ }
}
}
export async function materializePackage(prepared: PreparedPackage, receipt: PackageReceipt, outputDir: string, cwd: string): Promise<void> {
if (!outputDir || outputDir.includes("\0")) return invalidPackage();
const destination = resolve(cwd, outputDir), parent = dirname(destination);
await safeAncestors(parent);
try { await lstat(destination); return invalidPackage(); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
const staging = await mkdtemp(join(parent, ".prose-package-"));
const staged: Owned[] = [await identity(staging, true)];
const reserved: Owned[] = [];
const paths = [...prepared.files.map(file => ({ path: file.path, bytes: file.bytes })), { path: ".prose-package-receipt.json", bytes: new TextEncoder().encode(`${canonicalPackageJSON(receipt)}\n`) }];
const writeOwned = async (root: string, entries: Owned[], path: string, bytes: Uint8Array) => {
let directory = root;
const segments = path.split("/");
for (const part of segments.slice(0, -1)) {
directory = join(directory, part);
const known = entries.find(entry => entry.path === directory);
if (known !== undefined) { if (!(await stillOwned(known))) return invalidPackage(); }
else { await mkdir(directory, { mode: 0o700 }); entries.push(await identity(directory, true)); }
}
// Authenticate each owned ancestor immediately before the exclusive create.
for (const entry of entries.filter(entry => entry.directory)) if (!(await stillOwned(entry))) return invalidPackage();
await safeAncestors(directory);
const target = join(root, path);
const handle = await open(target, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
try {
const info = await handle.stat();
entries.push({ path: target, dev: info.dev, ino: info.ino, directory: false });
await handle.writeFile(bytes);
} finally { await handle.close(); }
};
try {
for (const file of paths) await writeOwned(staging, staged, file.path, file.bytes);
await safeAncestors(parent);
// Exclusive reservation never replaces even an empty destination. Visibility is
// deliberately non-atomic on hosts lacking an exposed NOREPLACE directory rename.
await mkdir(destination, { mode: 0o700 });
reserved.push(await identity(destination, true));
for (const file of paths) await writeOwned(destination, reserved, file.path, file.bytes);
for (const entry of reserved) if (!(await stillOwned(entry))) return invalidPackage();
} catch (error) {
await cleanupOwned(reserved);
throw error;
} finally { await cleanupOwned(staged); }
}
Loading
Loading