flomo-post 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/LICENSE +21 -0
- package/README.md +79 -0
- package/dist/cli.js +189 -0
- package/package.json +50 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 tmasjc
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# Flomo Post
|
|
2
|
+
|
|
3
|
+
Post notes to [Flomo](https://flomoapp.com) from the command line.
|
|
4
|
+
|
|
5
|
+
Zero runtime dependencies — a single small binary built on Node's native `fetch`.
|
|
6
|
+
|
|
7
|
+
## Requirements
|
|
8
|
+
|
|
9
|
+
- Node.js ≥ 18
|
|
10
|
+
- A Flomo account with API access (Pro account) — you need your personal
|
|
11
|
+
incoming-webhook URL, found in Flomo under **Settings → API**. It looks like
|
|
12
|
+
`https://flomoapp.com/iwh/<id>/<token>/`.
|
|
13
|
+
|
|
14
|
+
## Quick start
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
# one-time setup: paste your webhook URL at the prompt
|
|
18
|
+
npx flomo-post init
|
|
19
|
+
|
|
20
|
+
# post a note
|
|
21
|
+
npx flomo-post new "Reading notes on CLI design #dev"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Tags are just inline `#hashtags` — flomo parses them natively.
|
|
25
|
+
|
|
26
|
+
## Commands
|
|
27
|
+
|
|
28
|
+
| Command | Behavior |
|
|
29
|
+
|---|---|
|
|
30
|
+
| `flomo-post init` | Prompt for your webhook URL, validate it, save to the config file |
|
|
31
|
+
| `flomo-post test` | Post `Hello World!` with a timestamp (verifies your setup) |
|
|
32
|
+
| `flomo-post new <content...>` | Post the given content |
|
|
33
|
+
| `flomo-post --help` | Show usage |
|
|
34
|
+
| `flomo-post --version` | Show version |
|
|
35
|
+
|
|
36
|
+
`new` also reads piped stdin when no arguments are given:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
echo "captured from a pipeline #inbox" | flomo-post new
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Configuration
|
|
43
|
+
|
|
44
|
+
The webhook URL is resolved in precedence order:
|
|
45
|
+
|
|
46
|
+
1. `FLOMO_API_URL` environment variable
|
|
47
|
+
2. `~/.config/flomo-post/config.json` — written by `flomo-post init` with
|
|
48
|
+
file mode `600`
|
|
49
|
+
|
|
50
|
+
The env var is handy for scripts and CI; `init` is better for interactive
|
|
51
|
+
use since the URL never touches your shell history.
|
|
52
|
+
|
|
53
|
+
**The webhook URL is a secret** — it is the credential for your flomo
|
|
54
|
+
account. flomo-post never prints it: error messages redact it to
|
|
55
|
+
`https://flomoapp.com/iwh/****`.
|
|
56
|
+
|
|
57
|
+
## Exit codes
|
|
58
|
+
|
|
59
|
+
| Code | Meaning |
|
|
60
|
+
|---|---|
|
|
61
|
+
| `0` | Posted successfully |
|
|
62
|
+
| `1` | Network or API failure |
|
|
63
|
+
| `2` | Usage or configuration error |
|
|
64
|
+
|
|
65
|
+
Errors go to stderr; normal output to stdout.
|
|
66
|
+
|
|
67
|
+
## Development
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
npm install
|
|
71
|
+
npm test # vitest, fetch always mocked — never hits the network
|
|
72
|
+
npm run typecheck # tsc --noEmit, strict
|
|
73
|
+
npm run build # tsup → dist/cli.js
|
|
74
|
+
npm run dev -- new "from source" # run the CLI via tsx
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## License
|
|
78
|
+
|
|
79
|
+
MIT
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { realpathSync } from "fs";
|
|
5
|
+
import { createRequire } from "module";
|
|
6
|
+
import { createInterface } from "readline/promises";
|
|
7
|
+
import { pathToFileURL } from "url";
|
|
8
|
+
|
|
9
|
+
// src/post.ts
|
|
10
|
+
function redactUrl(_url) {
|
|
11
|
+
return "https://flomoapp.com/iwh/****";
|
|
12
|
+
}
|
|
13
|
+
async function postNote(apiUrl, content) {
|
|
14
|
+
const redact = (s) => s.split(apiUrl).join(redactUrl(apiUrl));
|
|
15
|
+
let res;
|
|
16
|
+
try {
|
|
17
|
+
res = await fetch(apiUrl, {
|
|
18
|
+
method: "POST",
|
|
19
|
+
headers: { "Content-Type": "application/json" },
|
|
20
|
+
body: JSON.stringify({ content, content_type: "markdown" })
|
|
21
|
+
});
|
|
22
|
+
} catch (err) {
|
|
23
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
24
|
+
const cause = err instanceof Error && err.cause instanceof Error ? `: ${err.cause.message}` : "";
|
|
25
|
+
return { ok: false, message: `Network error: ${redact(msg + cause)}` };
|
|
26
|
+
}
|
|
27
|
+
let body;
|
|
28
|
+
try {
|
|
29
|
+
body = await res.text();
|
|
30
|
+
} catch (err) {
|
|
31
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
32
|
+
return { ok: false, message: `Network error: ${redact(msg)}` };
|
|
33
|
+
}
|
|
34
|
+
let message = body;
|
|
35
|
+
try {
|
|
36
|
+
const parsed = JSON.parse(body);
|
|
37
|
+
if (parsed.message) message = parsed.message;
|
|
38
|
+
} catch {
|
|
39
|
+
}
|
|
40
|
+
if (!res.ok) {
|
|
41
|
+
return { ok: false, message: `API error (HTTP ${res.status}): ${message}` };
|
|
42
|
+
}
|
|
43
|
+
return { ok: true, message };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// src/config.ts
|
|
47
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
48
|
+
import { homedir } from "os";
|
|
49
|
+
import { dirname, join } from "path";
|
|
50
|
+
function getConfigPath() {
|
|
51
|
+
return join(homedir(), ".config", "flomo-post", "config.json");
|
|
52
|
+
}
|
|
53
|
+
function isValidApiUrl(url) {
|
|
54
|
+
return /^https:\/\/flomoapp\.com\/iwh\/.+/.test(url);
|
|
55
|
+
}
|
|
56
|
+
function resolveApiUrl() {
|
|
57
|
+
const envUrl = process.env.FLOMO_API_URL;
|
|
58
|
+
if (envUrl) return envUrl;
|
|
59
|
+
try {
|
|
60
|
+
const raw = readFileSync(getConfigPath(), "utf8");
|
|
61
|
+
const parsed = JSON.parse(raw);
|
|
62
|
+
return parsed.apiUrl ?? null;
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function saveApiUrl(url) {
|
|
68
|
+
const path = getConfigPath();
|
|
69
|
+
mkdirSync(dirname(path), { recursive: true, mode: 448 });
|
|
70
|
+
writeFileSync(path, JSON.stringify({ apiUrl: url }, null, 2) + "\n", { mode: 384 });
|
|
71
|
+
chmodSync(path, 384);
|
|
72
|
+
return path;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// src/cli.ts
|
|
76
|
+
var VERSION = createRequire(import.meta.url)("../package.json").version;
|
|
77
|
+
async function readAll(stream) {
|
|
78
|
+
const chunks = [];
|
|
79
|
+
for await (const chunk of stream) chunks.push(Buffer.from(chunk));
|
|
80
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
81
|
+
}
|
|
82
|
+
var HELP = `flomo-post \u2014 post notes to flomo
|
|
83
|
+
|
|
84
|
+
Usage:
|
|
85
|
+
flomo-post init Save your webhook URL to the config file
|
|
86
|
+
flomo-post test Post "Hello World!" with a timestamp
|
|
87
|
+
flomo-post new <content...> Post content (or pipe it via stdin)
|
|
88
|
+
flomo-post --help Show this help
|
|
89
|
+
flomo-post --version Show the version
|
|
90
|
+
|
|
91
|
+
Configuration (in precedence order):
|
|
92
|
+
FLOMO_API_URL Webhook URL (overrides the config file)
|
|
93
|
+
~/.config/flomo-post/config.json Written by \`flomo-post init\``;
|
|
94
|
+
async function main(argv, deps) {
|
|
95
|
+
const [command, ...rest] = argv;
|
|
96
|
+
if (command === "--help" || command === "-h") {
|
|
97
|
+
deps.stdout(HELP);
|
|
98
|
+
return 0;
|
|
99
|
+
}
|
|
100
|
+
if (command === "--version" || command === "-v") {
|
|
101
|
+
deps.stdout(VERSION);
|
|
102
|
+
return 0;
|
|
103
|
+
}
|
|
104
|
+
if (command === "init") return runInit(deps);
|
|
105
|
+
if (command === "test") {
|
|
106
|
+
return doPost(deps, `Hello World! ${(/* @__PURE__ */ new Date()).toISOString()}`);
|
|
107
|
+
}
|
|
108
|
+
if (command === "new") return runNew(rest, deps);
|
|
109
|
+
deps.stderr(HELP);
|
|
110
|
+
return 2;
|
|
111
|
+
}
|
|
112
|
+
async function runNew(args, deps) {
|
|
113
|
+
let content = args.join(" ").trim();
|
|
114
|
+
if (!content && !deps.isStdinTTY) {
|
|
115
|
+
content = (await deps.readStdin()).trim();
|
|
116
|
+
}
|
|
117
|
+
if (!content) {
|
|
118
|
+
deps.stderr("Usage: flomo-post new <content...> (or pipe content via stdin)");
|
|
119
|
+
return 2;
|
|
120
|
+
}
|
|
121
|
+
return doPost(deps, content);
|
|
122
|
+
}
|
|
123
|
+
async function doPost(deps, content) {
|
|
124
|
+
const apiUrl = deps.resolveApiUrl();
|
|
125
|
+
if (!apiUrl) {
|
|
126
|
+
deps.stderr("No API URL configured. Run `flomo-post init` or set FLOMO_API_URL.");
|
|
127
|
+
return 2;
|
|
128
|
+
}
|
|
129
|
+
const result = await deps.post(apiUrl, content);
|
|
130
|
+
if (!result.ok) {
|
|
131
|
+
deps.stderr(result.message);
|
|
132
|
+
return 1;
|
|
133
|
+
}
|
|
134
|
+
deps.stdout("Posted.");
|
|
135
|
+
return 0;
|
|
136
|
+
}
|
|
137
|
+
async function runInit(deps) {
|
|
138
|
+
const url = (await deps.promptForUrl()).trim();
|
|
139
|
+
if (!deps.isValidApiUrl(url)) {
|
|
140
|
+
deps.stderr("Invalid URL \u2014 expected the shape https://flomoapp.com/iwh/<id>/<token>/");
|
|
141
|
+
return 2;
|
|
142
|
+
}
|
|
143
|
+
const path = deps.saveApiUrl(url);
|
|
144
|
+
deps.stdout(`Saved to ${path}`);
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
|
147
|
+
function defaultDeps() {
|
|
148
|
+
return {
|
|
149
|
+
stdout: (line) => process.stdout.write(line + "\n"),
|
|
150
|
+
stderr: (line) => process.stderr.write(line + "\n"),
|
|
151
|
+
isStdinTTY: process.stdin.isTTY === true,
|
|
152
|
+
readStdin: () => readAll(process.stdin),
|
|
153
|
+
promptForUrl: () => new Promise((resolve, reject) => {
|
|
154
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
155
|
+
rl.once("close", () => reject(new Error("stdin closed before answer")));
|
|
156
|
+
rl.question("Paste your flomo webhook URL: ").then((answer) => {
|
|
157
|
+
resolve(answer);
|
|
158
|
+
rl.close();
|
|
159
|
+
}, reject);
|
|
160
|
+
}),
|
|
161
|
+
post: postNote,
|
|
162
|
+
resolveApiUrl,
|
|
163
|
+
saveApiUrl,
|
|
164
|
+
isValidApiUrl
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
function isDirectRun() {
|
|
168
|
+
if (!process.argv[1]) return false;
|
|
169
|
+
try {
|
|
170
|
+
return import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
|
|
171
|
+
} catch {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (isDirectRun()) {
|
|
176
|
+
main(process.argv.slice(2), defaultDeps()).then(
|
|
177
|
+
(code) => process.exit(code),
|
|
178
|
+
(err) => {
|
|
179
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
180
|
+
process.stderr.write(msg + "\n");
|
|
181
|
+
process.exit(1);
|
|
182
|
+
}
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
export {
|
|
186
|
+
defaultDeps,
|
|
187
|
+
main,
|
|
188
|
+
readAll
|
|
189
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "flomo-post",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Post notes to flomo from the command line",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"flomo",
|
|
7
|
+
"cli",
|
|
8
|
+
"notes",
|
|
9
|
+
"note-taking",
|
|
10
|
+
"webhook",
|
|
11
|
+
"memo"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/tmasjc/flomo-post-cli#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/tmasjc/flomo-post-cli/issues"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/tmasjc/flomo-post-cli.git"
|
|
20
|
+
},
|
|
21
|
+
"author": "tmasjc",
|
|
22
|
+
"type": "module",
|
|
23
|
+
"bin": {
|
|
24
|
+
"flomo-post": "dist/cli.js"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist"
|
|
28
|
+
],
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=18"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsup src/cli.ts --format esm --clean",
|
|
37
|
+
"test": "vitest run",
|
|
38
|
+
"typecheck": "tsc --noEmit",
|
|
39
|
+
"dev": "tsx src/cli.ts",
|
|
40
|
+
"prepublishOnly": "npm test && npm run build"
|
|
41
|
+
},
|
|
42
|
+
"license": "MIT",
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/node": "^26.1.1",
|
|
45
|
+
"tsup": "^8.5.1",
|
|
46
|
+
"tsx": "^4.23.1",
|
|
47
|
+
"typescript": "^7.0.2",
|
|
48
|
+
"vitest": "^4.1.10"
|
|
49
|
+
}
|
|
50
|
+
}
|