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
2 changes: 1 addition & 1 deletion docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
## Initial Design Record

- checked date: 2026-07-22
- repository version: `0.4.0`
- repository version: `0.4.1`
- implementation maturity: beta standalone Node Core/CLI
- split source: `backlog-api-skills` initial combined implementation

Expand Down
2 changes: 1 addition & 1 deletion docs/traceability/upstream-tool-mapping.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"target": {
"repository": "backlog-api",
"product": "backlog-api",
"version": "0.4.0",
"version": "0.4.1",
"strategy": "published-handler-direct-invocation"
},
"operations": [
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "backlog-api",
"version": "0.4.0",
"version": "0.4.1",
"private": true,
"type": "module",
"bin": {
Expand Down
2 changes: 1 addition & 1 deletion scripts/smoke-node.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ for (const args of [["--version"], ["--help"], ["tools", "list"]]) {

const runtime = await import("../bundle/backlog-api-runtime.mjs");
assert.equal(runtime.product.name, "backlog-api");
assert.equal(runtime.product.version, "0.4.0");
assert.equal(runtime.product.version, "0.4.1");
assert.equal(runtime.listOperations().length, 59);
assert.equal(
runtime.listOperations().find((operation) => operation.name === "get_rate_limit")
Expand Down
95 changes: 34 additions & 61 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@

import fs from "node:fs";
import { listOperations } from "./core/catalog.js";
import type {
BacklogAccessEvent,
CrudPermission,
RunOperationOptions
} from "./core/contracts.js";
import {
CliUsageError,
parseCliArguments
} from "./core/cli-arguments.js";
import type { BacklogAccessEvent, RunOperationOptions } from "./core/contracts.js";
import { getMapping } from "./core/traceability.js";
import { runOperation } from "./core/run-operation.js";
import { formatBacklogAccessEvent } from "./core/verbose-format.js";
Expand Down Expand Up @@ -55,6 +55,8 @@ Call options:
changed field names, pagination, and an exposed HTTP
failure status. Content values, credentials, personal
data, full arguments/results, and error text are omitted.
Each option may be specified once. Unknown options, duplicate options, and
extra positional arguments are usage errors.

Input JSON:
The input must be exactly one JSON object. Operation arguments are top-level
Expand Down Expand Up @@ -132,51 +134,55 @@ main().catch((error) => {

async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args.length === 0 || args.includes("--help") || args[0] === "help") {
let command;
try {
command = parseCliArguments(args);
} catch (error) {
if (!(error instanceof CliUsageError)) {
throw error;
}
process.stderr.write(`${error.message}\n`);
process.exitCode = 2;
return;
}

if (command.kind === "help") {
process.stdout.write(HELP);
return;
}
if (args.includes("--version")) {
if (command.kind === "version") {
process.stdout.write(`${product.version}\n`);
return;
}
if (args[0] === "tools" && args[1] === "list") {
if (command.kind === "tools-list") {
writeJson({ schemaVersion: 1, product, operations: listOperations() });
return;
}
if (args[0] === "trace") {
if (command.kind === "trace") {
const mapping = getMapping();
const operation = args[1];
writeJson(
operation
command.operation
? {
...mapping.upstream,
operation: mapping.operations.find((entry) => entry.operation === operation)
operation: mapping.operations.find(
(entry) => entry.operation === command.operation
)
}
: mapping
);
return;
}
if (args[0] === "call" && args[1]) {
const inputPath = optionValue(args, "--input") ?? "-";
let allowedPermissions;
try {
allowedPermissions = parseAllowedPermissions(optionValue(args, "--allow"));
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 2;
return;
}
const input = JSON.parse(await readInput(inputPath));
if (command.kind === "call") {
const input = JSON.parse(await readInput(command.inputPath));
const options: RunOperationOptions = {
dryRun: args.includes("--dry-run"),
confirmDestructive: args.includes("--confirm-destructive"),
allowedPermissions
dryRun: command.dryRun,
confirmDestructive: command.confirmDestructive,
allowedPermissions: command.allowedPermissions
};
if (args.includes("--verbose")) {
if (command.verbose) {
options.onAccess = writeVerboseEvent;
}
const result = await runOperation(args[1], input, options);
const result = await runOperation(command.operation, input, options);
writeJson(result);
if (!result.success) {
process.exitCode = result.diagnostics.some((diagnostic) =>
Expand All @@ -185,39 +191,6 @@ async function main(): Promise<void> {
}
return;
}

process.stderr.write("Unknown command. Use --help for usage.\n");
process.exitCode = 2;
}

function optionValue(args: string[], name: string): string | undefined {
const index = args.indexOf(name);
if (index < 0) {
return undefined;
}
const value = args[index + 1];
if (!value || value.startsWith("--")) {
throw new Error(`${name} requires a value.`);
}
return value;
}

function parseAllowedPermissions(value: string | undefined): CrudPermission[] {
if (value === undefined) {
return ["READ"];
}
const supported = new Set<CrudPermission>(["READ", "CREATE", "UPDATE", "DELETE"]);
const permissions = [...new Set(value.split(",").map((entry) => entry.trim().toUpperCase()))];
const invalid = permissions.filter(
(permission) => !supported.has(permission as CrudPermission)
);
if (invalid.length > 0) {
throw new Error(
`--allow contains unsupported permission(s): ${invalid.join(", ")}. ` +
"Use READ, CREATE, UPDATE, or DELETE."
);
}
return permissions as CrudPermission[];
}

async function readInput(inputPath: string): Promise<string> {
Expand Down
47 changes: 35 additions & 12 deletions src/core/access-permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,33 +2,56 @@ import type { CrudPermission } from "./contracts.js";

export const BACKLOG_API_ALLOWED_PERMISSIONS = "BACKLOG_API_ALLOWED_PERMISSIONS";

const DEFAULT_PERMISSIONS = Object.freeze(["READ"] satisfies CrudPermission[]);
const SUPPORTED_PERMISSIONS = new Set<CrudPermission>([
export const DEFAULT_CRUD_PERMISSIONS = Object.freeze(
["READ"] satisfies CrudPermission[]
);
export const CRUD_PERMISSIONS = Object.freeze([
"READ",
"CREATE",
"UPDATE",
"DELETE"
]);
] satisfies CrudPermission[]);

const SUPPORTED_PERMISSIONS = new Set<CrudPermission>(CRUD_PERMISSIONS);

export class CrudPermissionParseError extends Error {
readonly invalidValues: readonly string[];

constructor(invalidValues: readonly string[]) {
super("CRUD permissions contain empty or unsupported values.");
this.name = "CrudPermissionParseError";
this.invalidValues = invalidValues;
}
}

export function parseCrudPermissions(value: string): readonly CrudPermission[] {
const entries = value.split(",").map((entry) => entry.trim().toUpperCase());
const invalidValues = entries.filter(
(entry) => entry.length === 0 || !SUPPORTED_PERMISSIONS.has(entry as CrudPermission)
);
if (invalidValues.length > 0) {
throw new CrudPermissionParseError(invalidValues);
}
return [...new Set(entries)] as CrudPermission[];
}

export function environmentAllowedPermissions(
env: NodeJS.ProcessEnv
): readonly CrudPermission[] {
const value = env[BACKLOG_API_ALLOWED_PERMISSIONS];
if (value === undefined) {
return DEFAULT_PERMISSIONS;
return DEFAULT_CRUD_PERMISSIONS;
}

const entries = value.split(",").map((entry) => entry.trim().toUpperCase());
if (
entries.length === 0 ||
entries.some((entry) => entry.length === 0) ||
entries.some((entry) => !SUPPORTED_PERMISSIONS.has(entry as CrudPermission))
) {
try {
return parseCrudPermissions(value);
} catch (error) {
if (!(error instanceof CrudPermissionParseError)) {
throw error;
}
throw new Error(
`${BACKLOG_API_ALLOWED_PERMISSIONS} must be a comma-separated list containing only ` +
"READ, CREATE, UPDATE, and DELETE."
);
}

return [...new Set(entries)] as CrudPermission[];
}
Loading