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
package/src/cli.js
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// Argument parsing and dispatch.
|
|
2
|
+
//
|
|
3
|
+
// Commands are loaded on demand rather than imported up front, because `npx
|
|
4
|
+
// flostep` pays module-load cost on every single invocation and most runs touch
|
|
5
|
+
// exactly one command.
|
|
6
|
+
|
|
7
|
+
import { parseArgs } from "node:util";
|
|
8
|
+
|
|
9
|
+
import { CliError, UsageError } from "./errors.js";
|
|
10
|
+
import { Api } from "./api.js";
|
|
11
|
+
import { resolveHost, resolveToken } from "./config.js";
|
|
12
|
+
import { out, note, fail, dim, bold, cyan } from "./output.js";
|
|
13
|
+
import { VERSION } from "./version.js";
|
|
14
|
+
|
|
15
|
+
// Order is the order `--help` lists them, which is roughly the order someone
|
|
16
|
+
// meets them rather than alphabetical.
|
|
17
|
+
const COMMANDS = {
|
|
18
|
+
login: () => import("./commands/login.js"),
|
|
19
|
+
logout: () => import("./commands/logout.js"),
|
|
20
|
+
whoami: () => import("./commands/whoami.js"),
|
|
21
|
+
init: () => import("./commands/init.js"),
|
|
22
|
+
list: () => import("./commands/list.js"),
|
|
23
|
+
show: () => import("./commands/show.js"),
|
|
24
|
+
create: () => import("./commands/create.js"),
|
|
25
|
+
update: () => import("./commands/update.js"),
|
|
26
|
+
share: () => import("./commands/share.js"),
|
|
27
|
+
open: () => import("./commands/open.js"),
|
|
28
|
+
delete: () => import("./commands/delete.js"),
|
|
29
|
+
folder: () => import("./commands/folder.js"),
|
|
30
|
+
move: () => import("./commands/move.js"),
|
|
31
|
+
step: () => import("./commands/step.js"),
|
|
32
|
+
node: () => import("./commands/node.js"),
|
|
33
|
+
syntax: () => import("./commands/syntax.js")
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const GLOBAL_OPTIONS = {
|
|
37
|
+
json: { type: "boolean", default: false },
|
|
38
|
+
help: { type: "boolean", short: "h", default: false },
|
|
39
|
+
version: { type: "boolean", short: "v", default: false }
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async function load(name) {
|
|
44
|
+
const mod = await COMMANDS[name]();
|
|
45
|
+
return mod.default;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The tool's own description of its surface, built from the very objects
|
|
49
|
+
// `main` dispatches — so it cannot disagree with what the CLI actually accepts.
|
|
50
|
+
//
|
|
51
|
+
// This exists to be checked against prose. The reference lives in a *different*
|
|
52
|
+
// repository, where no test can see these files; `flostep --help --json` is
|
|
53
|
+
// what lets that repo assert its page still covers every command and flag.
|
|
54
|
+
export async function surface() {
|
|
55
|
+
const commands = [];
|
|
56
|
+
|
|
57
|
+
for (const name of Object.keys(COMMANDS)) {
|
|
58
|
+
const command = await load(name);
|
|
59
|
+
const options = Object.entries(command.options ?? {}).flatMap(([flag, spec]) =>
|
|
60
|
+
spec.short ? [ `--${flag}`, `-${spec.short}` ] : [ `--${flag}` ]
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
commands.push({
|
|
64
|
+
name,
|
|
65
|
+
summary: SUMMARIES[name],
|
|
66
|
+
usage: [].concat(command.usage),
|
|
67
|
+
options
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
version: VERSION,
|
|
73
|
+
global_options: Object.entries(GLOBAL_OPTIONS).flatMap(([flag, spec]) =>
|
|
74
|
+
spec.short ? [ `--${flag}`, `-${spec.short}` ] : [ `--${flag}` ]
|
|
75
|
+
),
|
|
76
|
+
commands
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function topLevelHelp() {
|
|
81
|
+
out(`${bold("flostep")} — build, update and share Flostep diagrams from the terminal.`);
|
|
82
|
+
out();
|
|
83
|
+
out(bold("USAGE"));
|
|
84
|
+
out(" flostep <command> [options]");
|
|
85
|
+
out();
|
|
86
|
+
out(bold("COMMANDS"));
|
|
87
|
+
const width = Math.max(...Object.keys(COMMANDS).map((n) => n.length));
|
|
88
|
+
for (const name of Object.keys(COMMANDS)) {
|
|
89
|
+
out(` ${name.padEnd(width)} ${SUMMARIES[name]}`);
|
|
90
|
+
}
|
|
91
|
+
out();
|
|
92
|
+
out(bold("GLOBAL OPTIONS"));
|
|
93
|
+
out(" --json machine-readable output");
|
|
94
|
+
out(" -h, --help show help for a command");
|
|
95
|
+
out(" -v, --version print the version");
|
|
96
|
+
out();
|
|
97
|
+
out(bold("GETTING STARTED"));
|
|
98
|
+
out(` ${cyan("flostep login")} sign in from a browser`);
|
|
99
|
+
out(` ${cyan("flostep create --title X --share")} from stdin, no files, returns a link`);
|
|
100
|
+
out(` ${cyan("flostep show 42 | flostep update 42")} read it, change it, write it back`);
|
|
101
|
+
out(` ${cyan("flostep init")} teach this repo's coding agent to use flostep`);
|
|
102
|
+
out();
|
|
103
|
+
out(dim("Steps are written one per line: `From -> To: what happens`."));
|
|
104
|
+
out(dim("Run `flostep syntax` for the full grammar, straight from the server."));
|
|
105
|
+
out();
|
|
106
|
+
out(dim("In CI, set FLOSTEP_TOKEN to a key from https://flostep.dev/api_keys."));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Duplicated here rather than loaded from every command module, because
|
|
110
|
+
// printing the command list would otherwise import every one of them and
|
|
111
|
+
// undo the lazy loading above.
|
|
112
|
+
const SUMMARIES = {
|
|
113
|
+
login: "sign in to Flostep from this machine",
|
|
114
|
+
logout: "forget the stored credentials",
|
|
115
|
+
whoami: "show the account and workspace a key writes to",
|
|
116
|
+
init: "tell this repo's coding agent how to use flostep",
|
|
117
|
+
list: "list the diagrams in the workspace",
|
|
118
|
+
show: "print a diagram as steps",
|
|
119
|
+
create: "create a diagram from stdin, writing no files",
|
|
120
|
+
update: "replace a diagram's steps from stdin",
|
|
121
|
+
share: "turn the public link on (or off) and print it",
|
|
122
|
+
open: "open a diagram in the browser",
|
|
123
|
+
delete: "delete a diagram",
|
|
124
|
+
folder: "list, create, rename or delete folders",
|
|
125
|
+
move: "file a diagram in a folder, or take it out",
|
|
126
|
+
step: "add, remove or list steps on a diagram",
|
|
127
|
+
node: "rename or list the components in a diagram",
|
|
128
|
+
syntax: "print the step grammar"
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
function commandHelp(command) {
|
|
132
|
+
out(`${bold(`flostep ${command.name}`)} — ${SUMMARIES[command.name]}`);
|
|
133
|
+
out();
|
|
134
|
+
out(bold("USAGE"));
|
|
135
|
+
for (const line of [].concat(command.usage)) out(` ${line}`);
|
|
136
|
+
|
|
137
|
+
if (command.details) {
|
|
138
|
+
out();
|
|
139
|
+
for (const line of [].concat(command.details)) out(` ${line}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (command.optionHelp) {
|
|
143
|
+
out();
|
|
144
|
+
out(bold("OPTIONS"));
|
|
145
|
+
for (const [flag, description] of command.optionHelp) {
|
|
146
|
+
out(` ${flag.padEnd(22)} ${description}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (command.examples) {
|
|
151
|
+
out();
|
|
152
|
+
out(bold("EXAMPLES"));
|
|
153
|
+
for (const [example, description] of command.examples) {
|
|
154
|
+
out(` ${cyan(example)}`);
|
|
155
|
+
if (description) out(` ${dim(description)}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function main(argv) {
|
|
161
|
+
const [name, ...rest] = argv;
|
|
162
|
+
|
|
163
|
+
const wantsJson = argv.includes("--json");
|
|
164
|
+
|
|
165
|
+
if (!name || name === "help") {
|
|
166
|
+
// --help is not an error; asking for it should exit 0 so a wrapper script
|
|
167
|
+
// can call it without tripping `set -e`.
|
|
168
|
+
if (!name && (argv.includes("--version") || argv.includes("-v"))) {
|
|
169
|
+
out(VERSION);
|
|
170
|
+
return 0;
|
|
171
|
+
}
|
|
172
|
+
if (wantsJson) {
|
|
173
|
+
out(JSON.stringify(await surface(), null, 2));
|
|
174
|
+
return 0;
|
|
175
|
+
}
|
|
176
|
+
topLevelHelp();
|
|
177
|
+
return 0;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (name === "--version" || name === "-v") {
|
|
181
|
+
out(VERSION);
|
|
182
|
+
return 0;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (name === "--help" || name === "-h") {
|
|
186
|
+
if (wantsJson) {
|
|
187
|
+
out(JSON.stringify(await surface(), null, 2));
|
|
188
|
+
return 0;
|
|
189
|
+
}
|
|
190
|
+
topLevelHelp();
|
|
191
|
+
return 0;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (!Object.hasOwn(COMMANDS, name)) {
|
|
195
|
+
throw new UsageError(`Unknown command "${name}".`, {
|
|
196
|
+
usage: "Run `flostep --help` to see the available commands."
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const command = await load(name);
|
|
201
|
+
|
|
202
|
+
let parsed;
|
|
203
|
+
try {
|
|
204
|
+
parsed = parseArgs({
|
|
205
|
+
args: rest,
|
|
206
|
+
options: { ...GLOBAL_OPTIONS, ...(command.options ?? {}) },
|
|
207
|
+
allowPositionals: true,
|
|
208
|
+
// Unknown flags are a usage error, not something to pass through — a
|
|
209
|
+
// silently ignored --tittle would push under the wrong title and look
|
|
210
|
+
// like it worked.
|
|
211
|
+
strict: true
|
|
212
|
+
});
|
|
213
|
+
} catch (cause) {
|
|
214
|
+
throw new UsageError(cause.message, { usage: [].concat(command.usage).join("\n") });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (parsed.values.help) {
|
|
218
|
+
commandHelp(command);
|
|
219
|
+
return 0;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const { token } = resolveToken();
|
|
223
|
+
const ctx = {
|
|
224
|
+
json: parsed.values.json,
|
|
225
|
+
token,
|
|
226
|
+
// Built lazily by the command, because `login` needs a host with no token
|
|
227
|
+
// and `logout` needs neither.
|
|
228
|
+
api() {
|
|
229
|
+
return new Api({ host: resolveHost(), token });
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
return (await command.run({ positionals: parsed.positionals, values: parsed.values, ctx })) ?? 0;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export async function run(argv) {
|
|
237
|
+
try {
|
|
238
|
+
return await main(argv);
|
|
239
|
+
} catch (error) {
|
|
240
|
+
if (error instanceof UsageError) {
|
|
241
|
+
fail(error.message);
|
|
242
|
+
if (error.usage) {
|
|
243
|
+
note();
|
|
244
|
+
note(`Usage: ${error.usage}`);
|
|
245
|
+
}
|
|
246
|
+
return error.exitCode;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (error instanceof CliError) {
|
|
250
|
+
fail(error.message, error.hint);
|
|
251
|
+
return error.exitCode;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Anything else is a bug in this tool rather than a problem with the
|
|
255
|
+
// request, and the stack is the only part of it worth reporting. Printed
|
|
256
|
+
// without asking: a flag to reveal it only helps the people who already
|
|
257
|
+
// know the flag, and everyone else reports "it crashed" with nothing
|
|
258
|
+
// attached. The frame lines are dimmed so the message still reads first.
|
|
259
|
+
fail(error.message || String(error));
|
|
260
|
+
for (const line of (error.stack ?? "").split("\n").slice(1)) note(dim(line));
|
|
261
|
+
note(dim(" This is a bug in flostep. Send the above to support@flostep.dev."));
|
|
262
|
+
return 1;
|
|
263
|
+
}
|
|
264
|
+
}
|
package/src/code.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// The step grammar, and the edits the granular commands make to it.
|
|
2
|
+
//
|
|
3
|
+
// This mirrors app/services/flow_parser.rb in the Flostep repo — same arrow
|
|
4
|
+
// pattern, same comment guard, same first-spelling-wins component identity.
|
|
5
|
+
// That repo already carries two independent parsers for this grammar and a
|
|
6
|
+
// warning to keep them in step; this is the third. Run `flostep syntax` to see
|
|
7
|
+
// the server's own description of it, which is served rather than copied for
|
|
8
|
+
// exactly this reason.
|
|
9
|
+
//
|
|
10
|
+
// Every edit is line-wise on purpose. Re-emitting from a parsed model would
|
|
11
|
+
// silently drop anything that isn't a step — blank lines and the `#` comments
|
|
12
|
+
// people write in their own files — and these commands are meant to change one
|
|
13
|
+
// step, not to reformat someone's file.
|
|
14
|
+
|
|
15
|
+
import { CliError } from "./errors.js";
|
|
16
|
+
|
|
17
|
+
// Kept identical to FlowParser::ARROW_RE and COMMENT_RE.
|
|
18
|
+
const ARROW_RE = /^(.+?)\s*-{1,2}>{1,2}\s*(.+?)\s*(?::\s*(.*))?$/;
|
|
19
|
+
const COMMENT_RE = /^(#|%%)/;
|
|
20
|
+
|
|
21
|
+
// Readability ceilings, mirrored from FlowParser so an oversized edit fails
|
|
22
|
+
// here with a clear message instead of as a 422 after a round trip.
|
|
23
|
+
export const MAX_COMPONENTS = 40;
|
|
24
|
+
export const MAX_STEPS = 120;
|
|
25
|
+
|
|
26
|
+
function parseLine(raw) {
|
|
27
|
+
const t = raw.trim();
|
|
28
|
+
if (!t || COMMENT_RE.test(t)) return null;
|
|
29
|
+
|
|
30
|
+
const m = t.match(ARROW_RE);
|
|
31
|
+
if (!m) return null;
|
|
32
|
+
|
|
33
|
+
const from = m[1].trim();
|
|
34
|
+
const to = m[2].trim();
|
|
35
|
+
if (!from || !to) return null;
|
|
36
|
+
|
|
37
|
+
return { from, to, label: (m[3] ?? "").trim() };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function formatStep({ from, to, label }) {
|
|
41
|
+
return label ? `${from} -> ${to}: ${label}` : `${from} -> ${to}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Steps in order, each carrying the line it came from so edits can target it.
|
|
45
|
+
export function parseSteps(code) {
|
|
46
|
+
const steps = [];
|
|
47
|
+
code.split("\n").forEach((raw, lineIndex) => {
|
|
48
|
+
const step = parseLine(raw);
|
|
49
|
+
if (step) steps.push({ ...step, lineIndex });
|
|
50
|
+
});
|
|
51
|
+
return steps;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// First appearance wins, matching how both server-side parsers create
|
|
55
|
+
// components — so this order is the order lifelines appear in sequence view.
|
|
56
|
+
export function componentsOf(code) {
|
|
57
|
+
const seen = new Map();
|
|
58
|
+
for (const { from, to } of parseSteps(code)) {
|
|
59
|
+
for (const name of [from, to]) {
|
|
60
|
+
const key = name.toLowerCase();
|
|
61
|
+
if (!seen.has(key)) seen.set(key, name);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return [...seen.values()];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function isStep(text) {
|
|
68
|
+
return parseLine(text) !== null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// `at` is a 1-based step position to insert before; omitted means append.
|
|
72
|
+
export function addStep(code, text, { at } = {}) {
|
|
73
|
+
const step = parseLine(text);
|
|
74
|
+
if (!step) {
|
|
75
|
+
throw new CliError(`Not a step: "${text.trim()}".`, {
|
|
76
|
+
hint: 'Expected "From -> To: what happens" — run `flostep syntax` for the format.'
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const steps = parseSteps(code);
|
|
81
|
+
if (steps.length + 1 > MAX_STEPS) {
|
|
82
|
+
throw new CliError(`That would make ${steps.length + 1} steps, past the limit of ${MAX_STEPS}.`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const lines = code.split("\n");
|
|
86
|
+
const line = formatStep(step);
|
|
87
|
+
|
|
88
|
+
if (at === undefined) {
|
|
89
|
+
// Trailing blank lines are common in a file that ends with a newline;
|
|
90
|
+
// appending after them would leave a gap.
|
|
91
|
+
while (lines.length && lines[lines.length - 1].trim() === "") lines.pop();
|
|
92
|
+
lines.push(line);
|
|
93
|
+
return lines.join("\n");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!Number.isInteger(at) || at < 1 || at > steps.length + 1) {
|
|
97
|
+
throw new CliError(`--at must be between 1 and ${steps.length + 1}.`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const insertAt = at === steps.length + 1 ? lines.length : steps[at - 1].lineIndex;
|
|
101
|
+
lines.splice(insertAt, 0, line);
|
|
102
|
+
return lines.join("\n");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// `n` counts steps, not lines — it matches the numbers `step list` prints and
|
|
106
|
+
// the badges on the canvas.
|
|
107
|
+
export function removeStep(code, n) {
|
|
108
|
+
const steps = parseSteps(code);
|
|
109
|
+
if (!Number.isInteger(n) || n < 1 || n > steps.length) {
|
|
110
|
+
throw new CliError(
|
|
111
|
+
steps.length === 0
|
|
112
|
+
? "That diagram has no steps to remove."
|
|
113
|
+
: `No step ${n}. This diagram has ${steps.length}.`,
|
|
114
|
+
{ hint: steps.length === 0 ? undefined : "Run `flostep step list <id>` to see the numbers." }
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const lines = code.split("\n");
|
|
119
|
+
lines.splice(steps[n - 1].lineIndex, 1);
|
|
120
|
+
return lines.join("\n");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Components are identified by lowercased name, so a rename has to match the
|
|
124
|
+
// same way — otherwise renaming "api" would leave "API" behind as a second box.
|
|
125
|
+
export function renameComponent(code, oldName, newName) {
|
|
126
|
+
const key = oldName.trim().toLowerCase();
|
|
127
|
+
const replacement = newName.trim();
|
|
128
|
+
if (!replacement) throw new CliError("The new name can't be empty.");
|
|
129
|
+
|
|
130
|
+
const known = componentsOf(code).map((c) => c.toLowerCase());
|
|
131
|
+
if (!known.includes(key)) {
|
|
132
|
+
throw new CliError(`No component named "${oldName}".`, {
|
|
133
|
+
hint: `This diagram has: ${componentsOf(code).join(", ") || "none"}.`
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
let changed = 0;
|
|
138
|
+
const lines = code.split("\n").map((raw) => {
|
|
139
|
+
const step = parseLine(raw);
|
|
140
|
+
if (!step) return raw;
|
|
141
|
+
|
|
142
|
+
const from = step.from.toLowerCase() === key ? replacement : step.from;
|
|
143
|
+
const to = step.to.toLowerCase() === key ? replacement : step.to;
|
|
144
|
+
if (from === step.from && to === step.to) return raw;
|
|
145
|
+
|
|
146
|
+
changed += 1;
|
|
147
|
+
return formatStep({ from, to, label: step.label });
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
return { code: lines.join("\n"), changed };
|
|
151
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// `flostep create` — a diagram from a pipe, touching no files.
|
|
2
|
+
//
|
|
3
|
+
// The whole write path. Steps arrive on a pipe and the diagram lives in the
|
|
4
|
+
// account — nothing is written to disk, so there is no file in someone else's
|
|
5
|
+
// checkout to keep in sync.
|
|
6
|
+
|
|
7
|
+
import { readStdin } from "../stdin.js";
|
|
8
|
+
import { out, ok, json, dim } from "../output.js";
|
|
9
|
+
import { diagramFromStdin, describe } from "../source.js";
|
|
10
|
+
|
|
11
|
+
export default {
|
|
12
|
+
name: "create",
|
|
13
|
+
usage: [
|
|
14
|
+
'flostep create [--title <title>] [--share]',
|
|
15
|
+
'flostep show 42 | flostep create --title "Copy"'
|
|
16
|
+
],
|
|
17
|
+
details: [
|
|
18
|
+
"Reads steps from stdin and creates a diagram. Writes no files.",
|
|
19
|
+
"Keep the id it prints — `update`, `share` and the rest take it."
|
|
20
|
+
],
|
|
21
|
+
options: {
|
|
22
|
+
title: { type: "string" },
|
|
23
|
+
share: { type: "boolean", default: false }
|
|
24
|
+
},
|
|
25
|
+
optionHelp: [
|
|
26
|
+
["--title <title>", "diagram title (default: Untitled)"],
|
|
27
|
+
["--share", "turn on the public link and print it, in the same call"]
|
|
28
|
+
],
|
|
29
|
+
examples: [
|
|
30
|
+
['flostep create --title "Checkout" --share < flow.txt', "one call, returns a link"],
|
|
31
|
+
['printf "A -> B: x\\n" | flostep create', "from any command that emits steps"]
|
|
32
|
+
],
|
|
33
|
+
|
|
34
|
+
async run({ values, ctx }) {
|
|
35
|
+
const input = await readStdin({ what: "`flostep create < flow.txt`, or a heredoc" });
|
|
36
|
+
// Checked here so a pipe carrying the wrong thing fails before it is sent.
|
|
37
|
+
const { code, summary } = diagramFromStdin(input);
|
|
38
|
+
const api = ctx.api();
|
|
39
|
+
|
|
40
|
+
const diagram = await api.createDiagram({ title: values.title, code });
|
|
41
|
+
// Sharing in the same call because "make me a diagram and send me the link"
|
|
42
|
+
// is one intention, and an agent paying two round trips for it will
|
|
43
|
+
// sometimes forget the second.
|
|
44
|
+
const shared = values.share ? await api.shareDiagram(diagram.id) : null;
|
|
45
|
+
|
|
46
|
+
if (ctx.json) {
|
|
47
|
+
json({ ...diagram, ...(shared ? { share_url: shared.share_url, embed_url: shared.embed_url } : {}) });
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
ok(`Created #${diagram.id} ${dim(`${diagram.title} — ${describe(summary)}`)}`);
|
|
52
|
+
out(shared ? shared.share_url : diagram.url);
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
|
|
3
|
+
import { CliError } from "../errors.js";
|
|
4
|
+
import { ok, note, json, dim, bold } from "../output.js";
|
|
5
|
+
import { resolveTarget } from "../target.js";
|
|
6
|
+
|
|
7
|
+
export default {
|
|
8
|
+
name: "delete",
|
|
9
|
+
usage: "flostep delete <id> [--yes]",
|
|
10
|
+
details: [
|
|
11
|
+
"Deletes the diagram and any comments on it. This cannot be undone."
|
|
12
|
+
],
|
|
13
|
+
options: { yes: { type: "boolean", short: "y", default: false } },
|
|
14
|
+
optionHelp: [["-y, --yes", "skip the confirmation prompt"]],
|
|
15
|
+
examples: [
|
|
16
|
+
["flostep delete 15"],
|
|
17
|
+
["flostep delete 15 --yes", "for scripts and agents"]
|
|
18
|
+
],
|
|
19
|
+
|
|
20
|
+
async run({ positionals, values, ctx }) {
|
|
21
|
+
const target = resolveTarget(positionals[0]);
|
|
22
|
+
const api = ctx.api();
|
|
23
|
+
const diagram = await api.getDiagram(target.id);
|
|
24
|
+
|
|
25
|
+
if (!values.yes) {
|
|
26
|
+
// Non-interactive and unconfirmed is refused rather than assumed either
|
|
27
|
+
// way: assuming yes deletes someone's work from a script that stalled,
|
|
28
|
+
// assuming no makes the command silently useless in CI.
|
|
29
|
+
if (!process.stdin.isTTY) {
|
|
30
|
+
throw new CliError("Refusing to delete without confirmation.", {
|
|
31
|
+
hint: "Pass --yes when running without a terminal."
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
36
|
+
// Ctrl-D rejects the question. That's someone backing out, not a bug, so
|
|
37
|
+
// it gets the same answer as "n" rather than a stack-trace hint.
|
|
38
|
+
const answer = await rl
|
|
39
|
+
.question(`Delete ${bold(diagram.title)} (#${diagram.id})? This cannot be undone. [y/N] `)
|
|
40
|
+
.catch(() => null);
|
|
41
|
+
rl.close();
|
|
42
|
+
|
|
43
|
+
if (answer == null || !/^y(es)?$/i.test(answer.trim())) {
|
|
44
|
+
note(dim("Nothing deleted."));
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
await api.deleteDiagram(target.id);
|
|
50
|
+
|
|
51
|
+
if (ctx.json) {
|
|
52
|
+
json({ id: diagram.id, title: diagram.title, deleted: true });
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
ok(`Deleted #${diagram.id} ${dim(diagram.title)}`);
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// `flostep folder` — the folders a workspace files its diagrams in.
|
|
2
|
+
//
|
|
3
|
+
// Folders are addressed by name, not id, as they are over MCP: names are what
|
|
4
|
+
// people say ("put it in Payments") and they are unique per workspace ignoring
|
|
5
|
+
// case. `flostep move` files a diagram into one; `flostep list --folder` reads
|
|
6
|
+
// one back.
|
|
7
|
+
|
|
8
|
+
import { createInterface } from "node:readline/promises";
|
|
9
|
+
|
|
10
|
+
import { CliError, UsageError } from "../errors.js";
|
|
11
|
+
import { out, ok, note, json, table, dim, bold } from "../output.js";
|
|
12
|
+
|
|
13
|
+
const SUBCOMMANDS = ["list", "create", "rename", "delete"];
|
|
14
|
+
|
|
15
|
+
export default {
|
|
16
|
+
name: "folder",
|
|
17
|
+
usage: [
|
|
18
|
+
"flostep folder list [--json]",
|
|
19
|
+
'flostep folder create "<name>"',
|
|
20
|
+
'flostep folder rename "<old>" "<new>"',
|
|
21
|
+
'flostep folder delete "<name>" [--yes]'
|
|
22
|
+
],
|
|
23
|
+
details: [
|
|
24
|
+
"Folders are shared by everyone in a team, and named case-insensitively.",
|
|
25
|
+
"Creating a folder that already exists returns it rather than failing.",
|
|
26
|
+
"Deleting a folder keeps its diagrams; they become uncategorized."
|
|
27
|
+
],
|
|
28
|
+
options: { yes: { type: "boolean", short: "y", default: false } },
|
|
29
|
+
optionHelp: [["-y, --yes", "delete without the confirmation prompt"]],
|
|
30
|
+
examples: [
|
|
31
|
+
["flostep folder list"],
|
|
32
|
+
['flostep folder create "Payments"'],
|
|
33
|
+
['flostep folder rename "Payments" "Billing"'],
|
|
34
|
+
['flostep folder delete "Billing" --yes', "for scripts and agents"]
|
|
35
|
+
],
|
|
36
|
+
|
|
37
|
+
async run({ positionals, values, ctx }) {
|
|
38
|
+
const [subcommand, ...args] = positionals;
|
|
39
|
+
|
|
40
|
+
if (!SUBCOMMANDS.includes(subcommand)) {
|
|
41
|
+
throw new UsageError(
|
|
42
|
+
subcommand ? `Unknown subcommand "folder ${subcommand}".` : "Which action? list, create, rename or delete.",
|
|
43
|
+
{ usage: this.usage.join("\n ") }
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const api = ctx.api();
|
|
48
|
+
|
|
49
|
+
if (subcommand === "list") {
|
|
50
|
+
const { folders, uncategorized } = await api.listFolders();
|
|
51
|
+
|
|
52
|
+
if (ctx.json) {
|
|
53
|
+
json({ folders, uncategorized });
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
if (folders.length === 0) {
|
|
57
|
+
out(dim("No folders yet. Create one with `flostep folder create <name>`."));
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
table(folders, [
|
|
61
|
+
["NAME", (f) => f.name],
|
|
62
|
+
["DIAGRAMS", (f) => f.diagrams, "right"]
|
|
63
|
+
]);
|
|
64
|
+
out(dim(`${uncategorized} uncategorized`));
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (subcommand === "create") {
|
|
69
|
+
const [name] = args;
|
|
70
|
+
if (!name?.trim()) {
|
|
71
|
+
throw new UsageError("Name the folder.", { usage: 'flostep folder create "<name>"' });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const folder = await api.createFolder(name);
|
|
75
|
+
if (ctx.json) {
|
|
76
|
+
json(folder);
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
if (folder.created) ok(`Created folder ${bold(folder.name)}`);
|
|
80
|
+
else ok(`Folder ${bold(folder.name)} already exists ${dim(`(${folder.diagrams} diagrams)`)}`);
|
|
81
|
+
return 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (subcommand === "rename") {
|
|
85
|
+
const [oldName, newName] = args;
|
|
86
|
+
if (!oldName || !newName?.trim()) {
|
|
87
|
+
throw new UsageError("Rename needs both the old and the new name.", {
|
|
88
|
+
usage: 'flostep folder rename "<old>" "<new>"'
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const folder = await api.findFolder(oldName);
|
|
93
|
+
const renamed = await api.renameFolder(folder.id, newName);
|
|
94
|
+
if (ctx.json) {
|
|
95
|
+
json(renamed);
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
ok(`Renamed folder ${dim(folder.name)} → ${bold(renamed.name)}`);
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// delete
|
|
103
|
+
const [name] = args;
|
|
104
|
+
if (!name) {
|
|
105
|
+
throw new UsageError("Which folder?", { usage: 'flostep folder delete "<name>" [--yes]' });
|
|
106
|
+
}
|
|
107
|
+
const folder = await api.findFolder(name);
|
|
108
|
+
|
|
109
|
+
if (!values.yes) {
|
|
110
|
+
// Same rule as `flostep delete`: unconfirmed without a terminal is refused
|
|
111
|
+
// rather than assumed either way. A folder delete un-files a whole team's
|
|
112
|
+
// diagrams at once, which is not something to do on a stalled script's behalf.
|
|
113
|
+
if (!process.stdin.isTTY) {
|
|
114
|
+
throw new CliError("Refusing to delete without confirmation.", {
|
|
115
|
+
hint: "Pass --yes when running without a terminal."
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
120
|
+
const count = `${folder.diagrams} ${folder.diagrams === 1 ? "diagram" : "diagrams"}`;
|
|
121
|
+
const answer = await rl
|
|
122
|
+
.question(`Delete folder ${bold(folder.name)}? Its ${count} will become uncategorized. [y/N] `)
|
|
123
|
+
.catch(() => null);
|
|
124
|
+
rl.close();
|
|
125
|
+
|
|
126
|
+
if (answer == null || !/^y(es)?$/i.test(answer.trim())) {
|
|
127
|
+
note(dim("Nothing deleted."));
|
|
128
|
+
return 0;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const result = await api.deleteFolder(folder.id);
|
|
133
|
+
if (ctx.json) {
|
|
134
|
+
json(result);
|
|
135
|
+
return 0;
|
|
136
|
+
}
|
|
137
|
+
const unfiled = result.diagrams_unfiled;
|
|
138
|
+
ok(`Deleted folder ${bold(folder.name)} ${dim(`(${unfiled} ${unfiled === 1 ? "diagram" : "diagrams"} now uncategorized)`)}`);
|
|
139
|
+
return 0;
|
|
140
|
+
}
|
|
141
|
+
};
|