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
117 changes: 105 additions & 12 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@ tmforge <command> [options] <file>
- **Help.** `tmforge --help` lists commands; `tmforge <command> --help` (also `-h`, `-?`) shows
command-specific options.
- **Version.** `tmforge --version` prints the released version.
- **Elements are addressed by GUID, alias, or unique name.** `add --alias <name>` gives an element a
stable, citeable id plus a handle that `connect`/`set`/`remove`/`rename`/`show` resolve (they also
accept a unique element name). Discover ids with `tmforge list` or the output of `add`.
- **Elements and flows are addressed by GUID, alias, or unique name.** `add --alias <name>` gives an
element a stable, citeable id plus a handle that `connect`/`set`/`remove`/`rename`/`show` resolve
(they also accept a unique name). A flow declared with an alias in a
[manifest](#declarative-manifest) is addressable the same way. Discover ids with `tmforge list` or
the output of `add`.

## Command summary

Expand Down Expand Up @@ -431,11 +433,20 @@ tmforge connect payments.tm7 \

### `remove`

Remove an element and its connected flows. The element is found on any page by default; `--page`
scopes the search to one page.
Remove an element and its connected flows, or remove a single flow. The object is found on any page
by default; `--page` scopes the search to one page.

```text
tmforge remove --id <guid> [--page <name|index>] [--json] <file>
tmforge remove --id <ref> [--page <name|index>] [--json] <file>
```

`--id` accepts a GUID, an **alias**, or a unique name. An alias resolves a flow as readily as an
element, so a flow given an alias in the manifest can be removed by that alias — and removing a flow
removes only that flow, leaving both endpoints in place.

```bash
tmforge remove payments.tm7 --id ORD # the store, plus every flow touching it
tmforge remove payments.tm7 --id checkout-request # just that one flow
```

### `rename`
Expand All @@ -448,17 +459,21 @@ tmforge rename --id <ref> --name <name> [--page <name|index>] [--json] <file>

### `set`

Set the name and/or properties of an existing element or flow by GUID. Use this to resolve linter
Set the name and/or properties of an existing element or flow. Use this to resolve linter
findings (e.g. add a missing `Protocol`) without recreating the element. List every settable property
and its allowed values with [`tmforge properties`](#properties).

```text
tmforge set --id <ref> [--name <name>] [--page <name|index>] [--property KEY=VALUE ...] [--json] <file>
```

`--id` accepts a GUID, an **alias**, or a unique name — and an alias or name resolves a **flow** just
as it resolves an element, so a flow given an alias in the manifest can be edited by that alias
instead of by its GUID.

```bash
tmforge set payments.tm7 --id 3333... --property Protocol=HTTPS --property Port=443
tmforge set payments.tm7 --id 3333... --name "Authenticated request" --property AuthenticationScheme=OAuth
tmforge set payments.tm7 --id checkout-request --property AuthenticationScheme=OAuth
```

### `page`
Expand Down Expand Up @@ -878,7 +893,7 @@ source of truth for the `.tm7`. `apply` materializes it; `export` emits it from
{ "alias": "DB", "kind": "store", "stencil": "azure-sql", "name": "Orders DB", "boundary": "TB1" }
],
"flows": [
{ "from": "API", "to": "DB", "name": "store order",
{ "alias": "F1", "from": "API", "to": "DB", "name": "store order",
"props": { "DataType": "Customer Content", "Protocol": "SQL", "Port": "1433" } }
]
}
Expand All @@ -888,8 +903,85 @@ source of truth for the `.tm7`. `apply` materializes it; `export` emits it from
inside it so trust-boundary crossings are computed and membership round-trips through `export`.
- `elements[].alias` gives each element a **deterministic** id (stable across rebuilds), and flows
reference elements by that alias (or by unique name).
- `flows[].alias` does the same for a flow. It is optional: a flow without one still gets a stable id,
derived from its endpoints and name. Declaring an alias is what lets a flow be **renamed or
re-pointed without moving its id** — worth doing for any flow whose findings you expect to track.
It is also the handle you pass to `set --id` or `remove --id` to edit that flow later, instead of
looking up its GUID.
- Either `kind` or `stencil` identifies an element; a stencil's base primitive sets the kind.

**Every identifier `apply` assigns is derived, never minted**, so applying one manifest twice produces
the same model: the same page, component, and connector ids. That is what keeps finding ids
(`{ruleId}:{diagram}:{target}:{occurrence}`), threat-register triage, and `tmforge diff` aligned
across rebuilds. Aliases are the durable form of that identity; the structural fallback is stable
against re-applying a manifest, but renaming an alias-less object moves its id.

### Envelope

A manifest may declare `"schema": "tmforge-manifest"` and a numeric `"version"`. Both are optional —
a manifest without them is read as the current version, because the concise form predates the
envelope. What the envelope buys is refusal rather than guesswork: a manifest from a newer build is
rejected with an upgrade hint instead of being read on assumptions that no longer hold, and a
document of another schema (a model, a rule pack) is named as such instead of being coerced into a
manifest that declares almost nothing. `export` stamps the envelope.

### Pages

A manifest may declare `pages`, and each boundary and element may name the one it is drawn on:

```json
{
"schema": "tmforge-manifest",
"version": 1,
"pages": [
{ "alias": "ctx", "name": "Context" },
{ "alias": "payments", "name": "Payments service" }
],
"elements": [
{ "alias": "API", "kind": "process", "name": "Checkout API", "page": "ctx" },
{ "alias": "LEDGER", "kind": "process", "name": "Ledger", "page": "payments" }
]
}
```

Pages are optional: a manifest that declares none builds onto one default page, exactly as before. An
object that names no page goes on the first. A page's alias (or its name) fixes its identifier, which
matters because finding ids embed it.

**A flow may not cross pages.** A connector belongs to one surface, so both endpoints have to be
drawn on the same page; a flow whose endpoints are on different pages is refused rather than drawn on
whichever page resolved first. Naming a page the manifest does not declare is refused too, rather than
quietly placing the object on the first page.

`export` records `pages` only when the model has more than one, so a single-page model produces
exactly the manifest it always did.

### Geometry

`boundaries[]` and `elements[]` accept optional `x`, `y`, `width`, and `height`. Position and size are
each all-or-nothing: supplying `x` without `y` is an error rather than a half-honoured request.

- Omit them and the object is placed automatically, exactly as before.
- Supply them and the object goes where you asked.
- A boundary with no size grows to contain any member you placed explicitly, rather than clipping it.
- A boundary with a fixed size that cannot hold the members it must lay out is rejected, because the
automatic grid would put them outside the box you drew.

A member placed outside the boundary it belongs to is allowed: nothing is being clipped, both objects
go where they were asked, and refusing it would make a diagram already in that state impossible to
re-apply.

`tmforge export --geometry` records the geometry. It is off by default because the manifest's job is
to be reviewable, and coordinates churn whenever a diagram is tidied — they bury the property change
somebody actually needs to read. **Turn it on when the layout matters.** Without it, `export` followed
by `apply` re-runs automatic placement, and because trust-boundary containment is derived from
geometry that can change which boundaries a flow crosses:

```bash
tmforge export --geometry --out payments.json payments.tm7 # lossless round trip
tmforge export --out payments.json payments.tm7 # concise, layout re-derived
```

### `apply`

Build a model from a manifest. The whole model is built in memory and written **atomically**, so a
Expand All @@ -913,15 +1005,16 @@ tmforge apply model.json --dry-run

### `export`

Emit a manifest from an existing model (round-trips with `apply`). Geometry is intentionally dropped
so the manifest stays a stable, diffable source.
Emit a manifest from an existing model (round-trips with `apply`). Geometry is dropped unless you ask
for it with `--geometry`, so the manifest stays a stable, diffable source by default.

```text
tmforge export [--out <manifest.json>] [--json] <model>
tmforge export [--out <manifest.json>] [--geometry] [--json] <model>
```

```bash
tmforge export payments.tm7 --out payments.json
tmforge export --geometry payments.tm7 --out payments.json
tmforge export payments.tm7 | jq '.elements'
```

Expand Down
2 changes: 1 addition & 1 deletion examples/webshop.tm7

Large diffs are not rendered by default.

15 changes: 2 additions & 13 deletions src/ThreatModelForge.Cli/ApplyCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,20 +55,9 @@ public static int Run(string[] args)
return 1;
}

Manifest? manifest;
try
if (!ManifestSupport.TryRead(File.ReadAllText(input!), out Manifest? manifest, out string? readError))
{
manifest = ManifestSupport.Deserialize(File.ReadAllText(input!));
}
catch (JsonException ex)
{
Console.Error.WriteLine("Invalid manifest JSON: " + ex.Message);
return 1;
}

if (manifest == null)
{
Console.Error.WriteLine("The manifest is empty.");
Console.Error.WriteLine(readError);
return 1;
}

Expand Down
8 changes: 5 additions & 3 deletions src/ThreatModelForge.Cli/ExportCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ public static int Run(string[] args)
return 1;
}

CliArgs parsed = CliArgs.Parse(args, new[] { "out" });
CliArgs parsed = CliArgs.Parse(args, new[] { "out" }, new[] { "geometry" });
if (parsed.Help)
{
PrintUsage();
Expand Down Expand Up @@ -53,7 +53,7 @@ public static int Run(string[] args)
}

(ThreatModel model, _) = CliModelLoader.Load(input!);
Manifest manifest = ManifestSupport.Extract(model);
Manifest manifest = ManifestSupport.Extract(model, parsed.HasFlag("geometry"));
string json = ManifestSupport.Serialize(manifest);

string? output = parsed.Get("out");
Expand Down Expand Up @@ -90,9 +90,11 @@ private static void PrintUsage()
{
Console.Error.WriteLine("Export a model as a declarative JSON manifest (round-trips with 'tmforge apply').");
Console.Error.WriteLine("Usage:");
Console.Error.WriteLine(" tmforge export [--out <manifest.json>] [--json] <model>");
Console.Error.WriteLine(" tmforge export [--out <manifest.json>] [--geometry] [--json] <model>");
Console.Error.WriteLine();
Console.Error.WriteLine("If --out is omitted, the manifest is written to standard output.");
Console.Error.WriteLine("--geometry records each object's position and size, so a hand-laid-out diagram");
Console.Error.WriteLine("survives a round trip. It is off by default to keep the manifest diffable.");
}
}
}
12 changes: 6 additions & 6 deletions src/ThreatModelForge.Cli/RemoveCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ namespace ThreatModelForge.Cli
using ThreatModelForge.Model;

/// <summary>
/// Implements the <c>tmforge remove</c> command: removes an element by GUID. Removing a
/// component also removes any data flows attached to it.
/// Implements the <c>tmforge remove</c> command: removes an element or a flow. Removing an
/// element also removes any data flows attached to it; removing a flow removes only that flow.
/// </summary>
internal static class RemoveCommand
{
Expand Down Expand Up @@ -83,20 +83,20 @@ public static int Run(string[] args)
}
else
{
Console.Error.WriteLine("Removed " + removed.Count + " element(s) from " + input + ".");
Console.Error.WriteLine("Removed " + removed.Count + " object(s) from " + input + ".");
}

return 0;
}

private static void PrintUsage()
{
Console.Error.WriteLine("Remove an element (and its connected flows).");
Console.Error.WriteLine("Remove an element (and its connected flows), or remove a single flow.");
Console.Error.WriteLine("Usage:");
Console.Error.WriteLine(" tmforge remove --id <ref> [--page <name|index>] [--json] <file>");
Console.Error.WriteLine();
Console.Error.WriteLine("--id accepts a GUID, an element --alias, or a unique element name.");
Console.Error.WriteLine("The element is found on any page by default; --page scopes the search to one page.");
Console.Error.WriteLine("--id accepts a GUID, an element or flow --alias, or a unique name.");
Console.Error.WriteLine("The object is found on any page by default; --page scopes the search to one page.");
}
}
}
2 changes: 1 addition & 1 deletion src/ThreatModelForge.Cli/SetCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ private static void PrintUsage()
Console.Error.WriteLine("Usage:");
Console.Error.WriteLine(" tmforge set --id <ref> [--name <name>] [--page <name|index>] [--property KEY=VALUE]... [--json] <file>");
Console.Error.WriteLine();
Console.Error.WriteLine("--id accepts a GUID, an element --alias, or a unique element name.");
Console.Error.WriteLine("--id accepts a GUID, an element or flow --alias, or a unique name.");
Console.Error.WriteLine();
Console.Error.WriteLine("Resolve analysis findings, e.g. --property Protocol=HTTPS --property Port=443,");
Console.Error.WriteLine("--property DataType=\"Customer Content\", or --property AuthenticationScheme=OAuth.");
Expand Down
Loading
Loading