flostep 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +105 -0
- package/LICENSE +21 -0
- package/README.md +112 -0
- package/bin/flostep.js +6 -0
- package/package.json +36 -0
- package/src/api.js +197 -0
- package/src/browser.js +46 -0
- package/src/cli.js +264 -0
- package/src/code.js +151 -0
- package/src/commands/create.js +55 -0
- package/src/commands/delete.js +59 -0
- package/src/commands/folder.js +141 -0
- package/src/commands/init.js +106 -0
- package/src/commands/list.js +56 -0
- package/src/commands/login.js +166 -0
- package/src/commands/logout.js +30 -0
- package/src/commands/move.js +48 -0
- package/src/commands/node.js +87 -0
- package/src/commands/open.js +29 -0
- package/src/commands/share.js +47 -0
- package/src/commands/show.js +30 -0
- package/src/commands/step.js +129 -0
- package/src/commands/syntax.js +17 -0
- package/src/commands/update.js +71 -0
- package/src/commands/whoami.js +39 -0
- package/src/config.js +67 -0
- package/src/errors.js +26 -0
- package/src/instructions.js +121 -0
- package/src/output.js +170 -0
- package/src/source.js +63 -0
- package/src/stdin.js +29 -0
- package/src/target.js +30 -0
- package/src/version.js +17 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// `flostep init` — make a repository's coding agent aware of flostep.
|
|
2
|
+
//
|
|
3
|
+
// The alternative is asking people to copy a block out of AGENTS.md, which is
|
|
4
|
+
// the step that gets skipped. Needs no login: it writes a text file.
|
|
5
|
+
|
|
6
|
+
import { createInterface } from "node:readline/promises";
|
|
7
|
+
import { relative, resolve } from "node:path";
|
|
8
|
+
|
|
9
|
+
import { out, ok, warn, note, dim, bold, json } from "../output.js";
|
|
10
|
+
import { agentBlock, projectRoot, detectTargets, planBlock, commitPlan } from "../instructions.js";
|
|
11
|
+
|
|
12
|
+
const DONE = {
|
|
13
|
+
created: "Created",
|
|
14
|
+
appended: "Added flostep to",
|
|
15
|
+
updated: "Updated",
|
|
16
|
+
unchanged: "Already up to date:"
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const PENDING = {
|
|
20
|
+
created: "create",
|
|
21
|
+
appended: "append to",
|
|
22
|
+
updated: "update the flostep block in"
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export default {
|
|
26
|
+
name: "init",
|
|
27
|
+
usage: "flostep init [--file <path>] [--print] [--yes]",
|
|
28
|
+
details: [
|
|
29
|
+
"Writes the flostep instructions into the agent instruction files at the repo root,",
|
|
30
|
+
"so a coding agent working there finds the CLI on its own.",
|
|
31
|
+
"",
|
|
32
|
+
"Updates every file that already exists — AGENTS.md, CLAUDE.md, and .cursor/rules/",
|
|
33
|
+
"(as flostep.mdc) — or creates AGENTS.md when there are none. The block sits between",
|
|
34
|
+
"marker comments: re-running replaces it, and nothing else in the file is touched.",
|
|
35
|
+
"",
|
|
36
|
+
"Asks before writing when run in a terminal. Without one, or with --json, it writes."
|
|
37
|
+
],
|
|
38
|
+
options: {
|
|
39
|
+
file: { type: "string" },
|
|
40
|
+
print: { type: "boolean", default: false },
|
|
41
|
+
yes: { type: "boolean", short: "y", default: false }
|
|
42
|
+
},
|
|
43
|
+
optionHelp: [
|
|
44
|
+
["--file <path>", "write to this file instead of detecting one"],
|
|
45
|
+
["--print", "print the block to stdout and write nothing"],
|
|
46
|
+
["-y, --yes", "skip the confirmation prompt"]
|
|
47
|
+
],
|
|
48
|
+
examples: [
|
|
49
|
+
["npx flostep init", "in the root of the repository"],
|
|
50
|
+
["flostep init --file docs/agents.md"],
|
|
51
|
+
["flostep init --print >> system-prompt.md", "for a prompt that isn't a file in a repo"]
|
|
52
|
+
],
|
|
53
|
+
|
|
54
|
+
async run({ values, ctx }) {
|
|
55
|
+
const block = agentBlock();
|
|
56
|
+
|
|
57
|
+
if (values.print) {
|
|
58
|
+
out(block);
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const root = projectRoot();
|
|
63
|
+
const targets = values.file ? [ resolve(values.file) ] : detectTargets(root);
|
|
64
|
+
const display = (path) => relative(root, path) || path;
|
|
65
|
+
const plans = targets.map((path) => planBlock(path, block));
|
|
66
|
+
const changes = plans.filter(({ contents }) => contents != null);
|
|
67
|
+
|
|
68
|
+
// Unlike `delete`, a missing terminal means go ahead rather than refuse:
|
|
69
|
+
// the change is additive, lives in version control, and re-running undoes
|
|
70
|
+
// nothing a person would miss. Refusing would make it useless in scripts.
|
|
71
|
+
const interactive = process.stdin.isTTY && !ctx.json && !values.yes;
|
|
72
|
+
|
|
73
|
+
if (interactive && changes.length > 0) {
|
|
74
|
+
note(`This will ${bold("write to your repository")}:`);
|
|
75
|
+
for (const { path, action } of changes) note(` ${dim("•")} ${PENDING[action]} ${display(path)}`);
|
|
76
|
+
|
|
77
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
78
|
+
// Ctrl-D rejects the question. That's someone backing out, not a bug, so
|
|
79
|
+
// it gets the same answer as "n" rather than a stack-trace hint.
|
|
80
|
+
const answer = await rl.question("Continue? [Y/n] ").catch(() => null);
|
|
81
|
+
rl.close();
|
|
82
|
+
|
|
83
|
+
if (answer == null || !/^(y(es)?)?$/i.test(answer.trim())) {
|
|
84
|
+
note(dim("Nothing written."));
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for (const plan of changes) commitPlan(plan);
|
|
90
|
+
|
|
91
|
+
if (ctx.json) {
|
|
92
|
+
json({ files: plans.map(({ path, action }) => ({ path: display(path), action })) });
|
|
93
|
+
return 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
for (const { path, action } of plans) {
|
|
97
|
+
if (action === "skipped") {
|
|
98
|
+
warn(`Skipped ${display(path)} — it already has a "## Flostep CLI" section, pasted by hand.`);
|
|
99
|
+
note(dim(" Delete that section and re-run to have init manage it from now on."));
|
|
100
|
+
} else {
|
|
101
|
+
ok(`${DONE[action]} ${display(path)}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { out, table, json, relativeTime, dim } from "../output.js";
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: "list",
|
|
5
|
+
usage: "flostep list [--folder <name>] [--json]",
|
|
6
|
+
details: [
|
|
7
|
+
"Every diagram in the workspace the key belongs to, most recently updated first.",
|
|
8
|
+
"--folder narrows it to one folder; `--folder uncategorized` to diagrams in none."
|
|
9
|
+
],
|
|
10
|
+
options: { folder: { type: "string" } },
|
|
11
|
+
optionHelp: [["--folder <name>", "only diagrams in this folder, or `uncategorized`"]],
|
|
12
|
+
examples: [
|
|
13
|
+
["flostep list"],
|
|
14
|
+
['flostep list --folder "Payments"'],
|
|
15
|
+
["flostep list --json", "ids and urls, for scripts and agents"]
|
|
16
|
+
],
|
|
17
|
+
|
|
18
|
+
async run({ values, ctx }) {
|
|
19
|
+
const api = ctx.api();
|
|
20
|
+
let folder;
|
|
21
|
+
if (values.folder !== undefined) {
|
|
22
|
+
// A real folder called "uncategorized" wins over the keyword, as it does
|
|
23
|
+
// over MCP — it's the one the person could only mean.
|
|
24
|
+
const wanted = values.folder.trim();
|
|
25
|
+
if (wanted.toLowerCase() === "uncategorized") {
|
|
26
|
+
const { folders } = await api.listFolders();
|
|
27
|
+
folder = folders.find((f) => f.name.toLowerCase() === "uncategorized")?.id ?? "uncategorized";
|
|
28
|
+
} else {
|
|
29
|
+
folder = (await api.findFolder(wanted)).id;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const diagrams = await api.listDiagrams({ folder });
|
|
34
|
+
|
|
35
|
+
if (ctx.json) {
|
|
36
|
+
json(diagrams);
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (diagrams.length === 0) {
|
|
41
|
+
out(dim(folder === undefined
|
|
42
|
+
? "No diagrams yet. Create one with `flostep create --title X --share`."
|
|
43
|
+
: "No diagrams in that folder."));
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
table(diagrams, [
|
|
48
|
+
["ID", (d) => d.id, "right"],
|
|
49
|
+
["TITLE", (d) => d.title],
|
|
50
|
+
["FOLDER", (d) => d.folder ?? ""],
|
|
51
|
+
["UPDATED", (d) => relativeTime(d.updated_at)],
|
|
52
|
+
["URL", (d) => d.url]
|
|
53
|
+
]);
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// `flostep login` — OAuth 2.0 device authorization grant (RFC 8628).
|
|
2
|
+
//
|
|
3
|
+
// The device grant rather than a localhost redirect, because this CLI is
|
|
4
|
+
// expected to run over SSH and inside containers, where a browser on the user's
|
|
5
|
+
// machine cannot reach a listener on the CLI's. Printing a code that works from
|
|
6
|
+
// any device is the only shape that survives that.
|
|
7
|
+
//
|
|
8
|
+
// It does not cover CI, and is not meant to: an Action has no browser. There,
|
|
9
|
+
// FLOSTEP_TOKEN holds a key minted on the website, and it takes precedence over
|
|
10
|
+
// anything stored here.
|
|
11
|
+
|
|
12
|
+
import { hostname } from "node:os";
|
|
13
|
+
|
|
14
|
+
import { CliError } from "../errors.js";
|
|
15
|
+
import { readConfig, writeConfig, resolveHost } from "../config.js";
|
|
16
|
+
import { note, json, box, fields, startSpinner, dim, bold, cyan, green } from "../output.js";
|
|
17
|
+
import { Api } from "../api.js";
|
|
18
|
+
import { openBrowser } from "../browser.js";
|
|
19
|
+
|
|
20
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
21
|
+
|
|
22
|
+
export default {
|
|
23
|
+
name: "login",
|
|
24
|
+
usage: [
|
|
25
|
+
"flostep login",
|
|
26
|
+
"flostep login --token <fls_...>"
|
|
27
|
+
],
|
|
28
|
+
details: [
|
|
29
|
+
"Prints a short code, opens the browser, and waits while you approve it.",
|
|
30
|
+
"The key it creates is listed on your API keys page and can be revoked there."
|
|
31
|
+
],
|
|
32
|
+
options: {
|
|
33
|
+
token: { type: "string" },
|
|
34
|
+
"no-browser": { type: "boolean", default: false }
|
|
35
|
+
},
|
|
36
|
+
optionHelp: [
|
|
37
|
+
["--token <fls_...>", "store a key you already have instead of opening a browser"],
|
|
38
|
+
["--no-browser", "print the URL rather than opening it"]
|
|
39
|
+
],
|
|
40
|
+
examples: [
|
|
41
|
+
["flostep login", "approve in a browser on any device"],
|
|
42
|
+
["flostep login --no-browser", "for SSH sessions where nothing can open"],
|
|
43
|
+
["FLOSTEP_TOKEN=fls_… flostep list", "CI needs no login at all — set the env var"]
|
|
44
|
+
],
|
|
45
|
+
|
|
46
|
+
async run({ values, ctx }) {
|
|
47
|
+
const host = resolveHost();
|
|
48
|
+
|
|
49
|
+
if (values.token) return storeToken({ host, token: values.token.trim(), ctx });
|
|
50
|
+
|
|
51
|
+
const api = ctx.api();
|
|
52
|
+
const grant = await api.requestDeviceCode(hostname());
|
|
53
|
+
|
|
54
|
+
note();
|
|
55
|
+
box([ `Your code: ${bold(cyan(grant.user_code))}` ]);
|
|
56
|
+
note();
|
|
57
|
+
|
|
58
|
+
const opened = values["no-browser"] ? false : await openBrowser(grant.verification_uri_complete);
|
|
59
|
+
note(
|
|
60
|
+
opened
|
|
61
|
+
? ` Opening ${dim(grant.verification_uri)} — approve the code there.`
|
|
62
|
+
: ` Open ${cyan(grant.verification_uri)} and enter it.`
|
|
63
|
+
);
|
|
64
|
+
note();
|
|
65
|
+
|
|
66
|
+
// Stopped on every path out, or the terminal is left with no cursor.
|
|
67
|
+
const stopSpinner = startSpinner("Waiting for approval…");
|
|
68
|
+
let token;
|
|
69
|
+
try {
|
|
70
|
+
token = await poll({ api, grant });
|
|
71
|
+
} finally {
|
|
72
|
+
stopSpinner();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return storeToken({ host, token: token.token, expiresAt: token.expires_at, ctx });
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
async function poll({ api, grant }) {
|
|
80
|
+
// The server tells us how fast to poll and for how long. Honouring both is
|
|
81
|
+
// what keeps this from being rate-limited into failing on a slow approval.
|
|
82
|
+
let interval = (grant.interval ?? 5) * 1000;
|
|
83
|
+
const deadline = Date.now() + (grant.expires_in ?? 600) * 1000;
|
|
84
|
+
|
|
85
|
+
while (Date.now() < deadline) {
|
|
86
|
+
await sleep(interval);
|
|
87
|
+
const body = await api.pollDeviceToken(grant.device_code);
|
|
88
|
+
|
|
89
|
+
if (body.token) return body;
|
|
90
|
+
|
|
91
|
+
switch (body.error) {
|
|
92
|
+
case "authorization_pending":
|
|
93
|
+
break;
|
|
94
|
+
case "slow_down":
|
|
95
|
+
// The RFC's own back-pressure. Backing off additively rather than
|
|
96
|
+
// resetting keeps a slow server from being hammered for ten minutes.
|
|
97
|
+
interval += 5000;
|
|
98
|
+
break;
|
|
99
|
+
case "access_denied":
|
|
100
|
+
throw new CliError("The request was denied in the browser.", {
|
|
101
|
+
hint: "Run `flostep login` again if that wasn't you."
|
|
102
|
+
});
|
|
103
|
+
case "expired_token":
|
|
104
|
+
throw new CliError("The code expired before it was approved.", {
|
|
105
|
+
hint: "Run `flostep login` again for a fresh one."
|
|
106
|
+
});
|
|
107
|
+
default:
|
|
108
|
+
throw new CliError(body.error_description || body.error || "Login failed.");
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
throw new CliError("Timed out waiting for approval.", {
|
|
113
|
+
hint: "Run `flostep login` again."
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function storeToken({ host, token, expiresAt, ctx }) {
|
|
118
|
+
if (!token.startsWith("fls_")) {
|
|
119
|
+
throw new CliError("That doesn't look like a Flostep API key (they start with `fls_`).");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// The host is stored alongside the token because they belong together — a key
|
|
123
|
+
// from a self-hosted instance is meaningless against flostep.dev, and storing
|
|
124
|
+
// only the token would silently send it to the wrong place.
|
|
125
|
+
writeConfig({ ...readConfig(), host, token });
|
|
126
|
+
|
|
127
|
+
// Built here rather than through ctx.api(), which captured the token as it
|
|
128
|
+
// was *before* this login — for `--token` that is null, so this would
|
|
129
|
+
// authenticate as nobody and pass.
|
|
130
|
+
//
|
|
131
|
+
// Called on every path, not just the pasted-key one: it verifies the token
|
|
132
|
+
// (a revoked key would otherwise report a successful login and fail on the
|
|
133
|
+
// next command, pointing the blame at that command), and it is where the
|
|
134
|
+
// workspace comes from. A key acts on a workspace, not a person, so "which
|
|
135
|
+
// library did I just point this terminal at?" is the question worth
|
|
136
|
+
// answering here — especially on a team, where it may not be your own.
|
|
137
|
+
const me = await new Api({ host, token }).me();
|
|
138
|
+
|
|
139
|
+
if (ctx.json) {
|
|
140
|
+
json({ email: me.email, workspace: me.workspace, host, expires_at: expiresAt ?? null });
|
|
141
|
+
return 0;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
note();
|
|
145
|
+
note(` ${green("✓")} ${bold("Connected")}`);
|
|
146
|
+
note();
|
|
147
|
+
fields([
|
|
148
|
+
[ "Account", me.email ],
|
|
149
|
+
[ "Workspace", `${me.workspace.name}${me.workspace.team ? dim(" (team)") : ""}` ],
|
|
150
|
+
[ "Plan", me.workspace.plan ],
|
|
151
|
+
...(expiresAt ? [ [ "Expires", formatExpiry(expiresAt) ] ] : [])
|
|
152
|
+
]);
|
|
153
|
+
note();
|
|
154
|
+
note(dim(` Next: ${cyan('flostep create --title "My flow" --share')}`));
|
|
155
|
+
note(dim(" steps on stdin, no files — or `flostep --help` for the rest."));
|
|
156
|
+
return 0;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// A named month rather than toLocaleDateString()'s default, which renders
|
|
160
|
+
// "28/11/2026" — unreadable without knowing whether the locale puts the day or
|
|
161
|
+
// the month first, and this line is the one thing a person reads about how long
|
|
162
|
+
// their session lasts. `undefined` keeps their locale's ordering; naming the
|
|
163
|
+
// month is what removes the ambiguity.
|
|
164
|
+
function formatExpiry(iso) {
|
|
165
|
+
return new Date(iso).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" });
|
|
166
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { clearConfig, resolveToken } from "../config.js";
|
|
2
|
+
import { ok, note, dim, json } from "../output.js";
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
name: "logout",
|
|
6
|
+
usage: "flostep logout",
|
|
7
|
+
details: [
|
|
8
|
+
"Removes the stored credentials from this machine.",
|
|
9
|
+
"The key itself keeps working until you revoke it on the API keys page."
|
|
10
|
+
],
|
|
11
|
+
examples: [["flostep logout"]],
|
|
12
|
+
|
|
13
|
+
async run({ ctx }) {
|
|
14
|
+
const { source } = resolveToken();
|
|
15
|
+
clearConfig();
|
|
16
|
+
|
|
17
|
+
if (ctx.json) {
|
|
18
|
+
json({ logged_out: true });
|
|
19
|
+
return 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
ok("Logged out.");
|
|
23
|
+
// Silently doing nothing would be indistinguishable from success, and the
|
|
24
|
+
// next command would still be authenticated — which reads as a bug.
|
|
25
|
+
if (source === "FLOSTEP_TOKEN") {
|
|
26
|
+
note(dim(" FLOSTEP_TOKEN is still set in this environment, so commands remain authenticated."));
|
|
27
|
+
}
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// `flostep move` — file a diagram in a folder, or take it out of one.
|
|
2
|
+
//
|
|
3
|
+
// Moving never changes the diagram, so it works on read-only diagrams too and
|
|
4
|
+
// leaves any local file alone.
|
|
5
|
+
|
|
6
|
+
import { UsageError } from "../errors.js";
|
|
7
|
+
import { ok, json, dim, bold } from "../output.js";
|
|
8
|
+
import { resolveTarget } from "../target.js";
|
|
9
|
+
|
|
10
|
+
export default {
|
|
11
|
+
name: "move",
|
|
12
|
+
usage: [
|
|
13
|
+
'flostep move <id> "<folder>"',
|
|
14
|
+
"flostep move <id> --none"
|
|
15
|
+
],
|
|
16
|
+
details: ["The folder must already exist — see `flostep folder list`, or `flostep folder create`."],
|
|
17
|
+
options: { none: { type: "boolean", default: false } },
|
|
18
|
+
optionHelp: [["--none", "take the diagram out of its folder (uncategorized)"]],
|
|
19
|
+
examples: [
|
|
20
|
+
['flostep move 42 "Payments"'],
|
|
21
|
+
["flostep move 42 --none"]
|
|
22
|
+
],
|
|
23
|
+
|
|
24
|
+
async run({ positionals, values, ctx }) {
|
|
25
|
+
const [ref, folderName] = positionals;
|
|
26
|
+
|
|
27
|
+
if (!ref || (!folderName && !values.none) || (folderName && values.none)) {
|
|
28
|
+
throw new UsageError("Pass a diagram and either a folder name or --none.", {
|
|
29
|
+
usage: this.usage.join("\n ")
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const target = resolveTarget(ref);
|
|
34
|
+
const api = ctx.api();
|
|
35
|
+
const folder = values.none ? null : await api.findFolder(folderName);
|
|
36
|
+
const moved = await api.moveDiagram(target.id, folder?.id ?? null);
|
|
37
|
+
|
|
38
|
+
if (ctx.json) {
|
|
39
|
+
json(moved);
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
ok(moved.folder
|
|
44
|
+
? `Moved #${moved.id} ${dim(moved.title)} to ${bold(moved.folder)}`
|
|
45
|
+
: `Moved #${moved.id} ${dim(moved.title)} out of its folder`);
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// `flostep node` — the components in a diagram.
|
|
2
|
+
//
|
|
3
|
+
// Deliberately only rename and list. There is no `node add` and no `--type`,
|
|
4
|
+
// and that is a property of the format rather than an omission: the text
|
|
5
|
+
// grammar has no way to express a component with no connections (it is written
|
|
6
|
+
// as a side of a step, so an unconnected one cannot be written at all), and no
|
|
7
|
+
// way to express a type — the server infers that from the name. A `--type cache`
|
|
8
|
+
// flag would be discarded on the next write, which is worse than not offering it.
|
|
9
|
+
//
|
|
10
|
+
// To add a component, name it in a step: `flostep step add 15 "API -> Redis: read"`.
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
import { UsageError } from "../errors.js";
|
|
14
|
+
import { out, ok, json, dim } from "../output.js";
|
|
15
|
+
import { resolveTarget } from "../target.js";
|
|
16
|
+
import { componentsOf, renameComponent } from "../code.js";
|
|
17
|
+
|
|
18
|
+
const SUBCOMMANDS = ["rename", "list"];
|
|
19
|
+
|
|
20
|
+
export default {
|
|
21
|
+
name: "node",
|
|
22
|
+
usage: [
|
|
23
|
+
'flostep node rename <id> "<old>" "<new>"',
|
|
24
|
+
"flostep node list <id> [--json]"
|
|
25
|
+
],
|
|
26
|
+
details: [
|
|
27
|
+
"Components are identified by name, case-insensitively, so a rename changes",
|
|
28
|
+
"every step that mentions them. To add one, name it in a new step.",
|
|
29
|
+
"Types (service, database, person…) are inferred from the name by the server."
|
|
30
|
+
],
|
|
31
|
+
examples: [
|
|
32
|
+
["flostep node list 15"],
|
|
33
|
+
['flostep node rename 15 "Redis" "Session Cache"'],
|
|
34
|
+
["flostep node list 15 --json", "components in first-appearance order"]
|
|
35
|
+
],
|
|
36
|
+
|
|
37
|
+
async run({ positionals, ctx }) {
|
|
38
|
+
const [subcommand, ref, ...rest] = positionals;
|
|
39
|
+
|
|
40
|
+
if (!SUBCOMMANDS.includes(subcommand)) {
|
|
41
|
+
throw new UsageError(
|
|
42
|
+
subcommand ? `Unknown subcommand "node ${subcommand}".` : "Which action? rename or list.",
|
|
43
|
+
{ usage: this.usage.join("\n ") }
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const target = resolveTarget(ref);
|
|
48
|
+
const api = ctx.api();
|
|
49
|
+
const diagram = await api.getDiagram(target.id);
|
|
50
|
+
|
|
51
|
+
if (subcommand === "list") {
|
|
52
|
+
const components = componentsOf(diagram.code);
|
|
53
|
+
|
|
54
|
+
if (ctx.json) {
|
|
55
|
+
json(components.map((name, i) => ({ n: i + 1, name })));
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
if (components.length === 0) {
|
|
59
|
+
out(dim("No components yet."));
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
62
|
+
// First-appearance order, which is the order the server creates them and
|
|
63
|
+
// therefore the left-to-right order of lifelines in sequence view.
|
|
64
|
+
components.forEach((name) => out(name));
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const [oldName, newName] = rest;
|
|
69
|
+
if (!oldName || !newName) {
|
|
70
|
+
throw new UsageError("Rename needs both the old and the new name.", {
|
|
71
|
+
usage: 'flostep node rename <id> "<old>" "<new>"'
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const { code, changed } = renameComponent(diagram.code, oldName, newName);
|
|
76
|
+
// As in `step`: only if nothing changed since the read above.
|
|
77
|
+
await api.updateDiagram(target.id, { code, version: diagram.version });
|
|
78
|
+
|
|
79
|
+
if (ctx.json) {
|
|
80
|
+
json({ id: diagram.id, from: oldName, to: newName, steps_changed: changed, code });
|
|
81
|
+
return 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
ok(`Renamed ${dim(oldName)} → ${newName} ${dim(`(${changed} ${changed === 1 ? "step" : "steps"})`)}`);
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { out, ok, note, dim } from "../output.js";
|
|
2
|
+
import { openBrowser } from "../browser.js";
|
|
3
|
+
import { resolveTarget } from "../target.js";
|
|
4
|
+
|
|
5
|
+
export default {
|
|
6
|
+
name: "open",
|
|
7
|
+
usage: "flostep open <id>",
|
|
8
|
+
details: ["Opens the diagram in the editor in your default browser."],
|
|
9
|
+
examples: [
|
|
10
|
+
["flostep open 15"],
|
|
11
|
+
["flostep open 42"]
|
|
12
|
+
],
|
|
13
|
+
|
|
14
|
+
async run({ positionals, ctx }) {
|
|
15
|
+
const { id } = resolveTarget(positionals[0]);
|
|
16
|
+
const diagram = await ctx.api().getDiagram(id);
|
|
17
|
+
|
|
18
|
+
// Awaited: whether a browser actually started is only known a tick later,
|
|
19
|
+
// and headless is a normal place to run this — the URL is the useful half.
|
|
20
|
+
if (await openBrowser(diagram.url)) {
|
|
21
|
+
ok(`Opening ${diagram.title}`);
|
|
22
|
+
out(diagram.url);
|
|
23
|
+
} else {
|
|
24
|
+
out(diagram.url);
|
|
25
|
+
note(dim("(couldn't open a browser here — the URL is above)"));
|
|
26
|
+
}
|
|
27
|
+
return 0;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { out, ok, json, dim } from "../output.js";
|
|
2
|
+
import { resolveTarget } from "../target.js";
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
name: "share",
|
|
6
|
+
usage: "flostep share <id> [--off] [--embed]",
|
|
7
|
+
details: [
|
|
8
|
+
"Turns on the public link and prints it — the natural last step after `create` or `update`.",
|
|
9
|
+
"The link is read-only and needs no account; viewers can step through the flow."
|
|
10
|
+
],
|
|
11
|
+
options: {
|
|
12
|
+
off: { type: "boolean", default: false },
|
|
13
|
+
embed: { type: "boolean", default: false }
|
|
14
|
+
},
|
|
15
|
+
optionHelp: [
|
|
16
|
+
["--off", "stop sharing (the URL is kept, so re-sharing restores it)"],
|
|
17
|
+
["--embed", "print the iframe URL instead of the page URL"]
|
|
18
|
+
],
|
|
19
|
+
examples: [
|
|
20
|
+
["flostep share 15"],
|
|
21
|
+
["flostep share 42 --embed", "for a README or Confluence page"],
|
|
22
|
+
["flostep share 15 --off"]
|
|
23
|
+
],
|
|
24
|
+
|
|
25
|
+
async run({ positionals, values, ctx }) {
|
|
26
|
+
const { id } = resolveTarget(positionals[0]);
|
|
27
|
+
const api = ctx.api();
|
|
28
|
+
|
|
29
|
+
const result = values.off ? await api.unshareDiagram(id) : await api.shareDiagram(id);
|
|
30
|
+
|
|
31
|
+
if (ctx.json) {
|
|
32
|
+
json(result);
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (values.off) {
|
|
37
|
+
ok(`Sharing off for #${result.id} ${dim(result.title)}`);
|
|
38
|
+
// Worth saying: people expect "unshare" to burn the URL, and it doesn't.
|
|
39
|
+
out(dim("The link is kept — sharing again restores the same URL."));
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
ok(`Sharing #${result.id} ${dim(result.title)}`);
|
|
44
|
+
out(values.embed ? result.embed_url : result.share_url);
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { out, json } from "../output.js";
|
|
2
|
+
import { resolveTarget } from "../target.js";
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
name: "show",
|
|
6
|
+
usage: "flostep show <id>",
|
|
7
|
+
details: [
|
|
8
|
+
"Prints the diagram as steps, and nothing else, so it pipes cleanly:",
|
|
9
|
+
" flostep show 15 > docs/checkout.flostep"
|
|
10
|
+
],
|
|
11
|
+
examples: [
|
|
12
|
+
["flostep show 15"],
|
|
13
|
+
["flostep show 42 > docs/checkout.flostep", "redirect it yourself if you want a file"],
|
|
14
|
+
["flostep show 15 --json", "with the title and url alongside the code"]
|
|
15
|
+
],
|
|
16
|
+
|
|
17
|
+
async run({ positionals, ctx }) {
|
|
18
|
+
const { id } = resolveTarget(positionals[0]);
|
|
19
|
+
const diagram = await ctx.api().getDiagram(id);
|
|
20
|
+
|
|
21
|
+
if (ctx.json) {
|
|
22
|
+
json(diagram);
|
|
23
|
+
return 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// No trailing decoration — this is the pipeable one.
|
|
27
|
+
out(diagram.code);
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
30
|
+
};
|