@plannotator/tot 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 +59 -0
- package/dist/asset-refs.d.ts +16 -0
- package/dist/asset-refs.js +204 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +107 -0
- package/dist/commands.d.ts +42 -0
- package/dist/commands.js +250 -0
- package/dist/config.d.ts +64 -0
- package/dist/config.js +143 -0
- package/dist/http.d.ts +70 -0
- package/dist/http.js +145 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +6 -0
- package/package.json +60 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Plannotator
|
|
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,59 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="./site/assets/readme/totpage2.webp" alt="tot" width="700">
|
|
3
|
+
<br>
|
|
4
|
+
<sub><a href="https://tot.page">tot.page</a> is what enables <a href="https://plannotator.ai/workspaces">Plannotator Workspaces</a></sub>
|
|
5
|
+
</p>
|
|
6
|
+
|
|
7
|
+
# tot.page
|
|
8
|
+
|
|
9
|
+
Publish a markdown or HTML file to a live link in one command. No signup.
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm i -g @plannotator/tot
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
tot notes.md
|
|
17
|
+
↳ https://tot.page/aB3xK9q
|
|
18
|
+
commit e5f6c1a
|
|
19
|
+
frozen https://tot.page/aB3xK9q/index.md@e5f6c1a
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Commands
|
|
23
|
+
|
|
24
|
+
| Command | What it does |
|
|
25
|
+
| ----------------------- | -------------------------------------------------------------------------------------- |
|
|
26
|
+
| `tot notes.md` | Publish markdown as the raw `.md` file. |
|
|
27
|
+
| `tot page.html` | Publish HTML as the raw `.html` file, plus local support files it directly references. |
|
|
28
|
+
| `tot update <link>` | Push new content. The same link updates. |
|
|
29
|
+
| `tot list` | Show what you have published. |
|
|
30
|
+
| `tot remove <link>` | Remove the living page from its share link. |
|
|
31
|
+
| `tot login --key <key>` | Optional. Publish as an owned account instead of anonymous. |
|
|
32
|
+
|
|
33
|
+
## How it works
|
|
34
|
+
|
|
35
|
+
Files are served byte for byte. Markdown comes back as raw markdown. HTML comes back as raw HTML.
|
|
36
|
+
|
|
37
|
+
For HTML, `tot` also uploads direct local browser dependencies before the page goes live: images,
|
|
38
|
+
stylesheets, scripts, video, `srcset` entries, and video posters. It skips external URLs and ordinary
|
|
39
|
+
navigation links. There is no config file, build step, routing layer, or bundler.
|
|
40
|
+
|
|
41
|
+
Your link is live. Run `tot update` and the same `tot.page/...` link shows the new version. Every version also keeps a frozen `@hash` link that never changes, for when you want a fixed snapshot.
|
|
42
|
+
|
|
43
|
+
`tot remove` removes the living page. Frozen snapshot links are permanent while the workspace exists.
|
|
44
|
+
|
|
45
|
+
No accounts, no tokens. The link is the key.
|
|
46
|
+
|
|
47
|
+
> A page you publish is open. Anyone who has the link can view it, update it, or delete it. There is no private mode. Share the link with that in mind.
|
|
48
|
+
|
|
49
|
+
## Configuration
|
|
50
|
+
|
|
51
|
+
State lives in `~/.tot`: the API endpoint and the list of pages you have published. Override the API origin for one run with `--endpoint <url>`.
|
|
52
|
+
|
|
53
|
+
## Built on
|
|
54
|
+
|
|
55
|
+
[Cloudflare Artifacts](https://www.cloudflare.com/products/artifacts/). Every version is a real git commit.
|
|
56
|
+
|
|
57
|
+
## License
|
|
58
|
+
|
|
59
|
+
MIT
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type AssetContentType = "image/png" | "image/jpeg" | "image/gif" | "image/webp" | "image/svg+xml" | "text/css" | "application/javascript" | "video/mp4";
|
|
2
|
+
export interface HtmlAssetRef {
|
|
3
|
+
/** The literal URL-like value from the HTML, before query/hash stripping. */
|
|
4
|
+
ref: string;
|
|
5
|
+
/** Workspace asset path to upload, relative to the HTML file's folder. */
|
|
6
|
+
assetPath: string;
|
|
7
|
+
/** Absolute local file path to read. */
|
|
8
|
+
localPath: string;
|
|
9
|
+
contentType: AssetContentType;
|
|
10
|
+
}
|
|
11
|
+
export declare const MAX_ASSET_BYTES: number;
|
|
12
|
+
export declare const MAX_ASSET_BYTES_LABEL = "10 MiB";
|
|
13
|
+
export declare function contentTypeForAssetPath(assetPath: string): AssetContentType | null;
|
|
14
|
+
export declare function validWorkspacePath(value: string): boolean;
|
|
15
|
+
export declare function encodeWorkspacePath(value: string): string;
|
|
16
|
+
export declare function collectHtmlAssetRefs(html: string, htmlFilePath: string): HtmlAssetRef[];
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import * as parse5 from "parse5";
|
|
3
|
+
export const MAX_ASSET_BYTES = 10 * 1024 * 1024;
|
|
4
|
+
export const MAX_ASSET_BYTES_LABEL = "10 MiB";
|
|
5
|
+
const CONTENT_TYPES_BY_EXT = {
|
|
6
|
+
".png": "image/png",
|
|
7
|
+
".jpg": "image/jpeg",
|
|
8
|
+
".jpeg": "image/jpeg",
|
|
9
|
+
".gif": "image/gif",
|
|
10
|
+
".webp": "image/webp",
|
|
11
|
+
".svg": "image/svg+xml",
|
|
12
|
+
".css": "text/css",
|
|
13
|
+
".js": "application/javascript",
|
|
14
|
+
".mjs": "application/javascript",
|
|
15
|
+
".mp4": "video/mp4",
|
|
16
|
+
};
|
|
17
|
+
export function contentTypeForAssetPath(assetPath) {
|
|
18
|
+
return CONTENT_TYPES_BY_EXT[path.extname(assetPath).toLowerCase()] ?? null;
|
|
19
|
+
}
|
|
20
|
+
export function validWorkspacePath(value) {
|
|
21
|
+
if (value.length === 0)
|
|
22
|
+
return false;
|
|
23
|
+
if (value.startsWith("/") || value.endsWith("/") || value.includes("//"))
|
|
24
|
+
return false;
|
|
25
|
+
if (/[\\?#%]/u.test(value))
|
|
26
|
+
return false;
|
|
27
|
+
for (let i = 0; i < value.length; i++) {
|
|
28
|
+
if (value.charCodeAt(i) < 0x20)
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
for (const segment of value.split("/")) {
|
|
32
|
+
if (segment === "." || segment === "..")
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
export function encodeWorkspacePath(value) {
|
|
38
|
+
return value.split("/").map(encodeURIComponent).join("/");
|
|
39
|
+
}
|
|
40
|
+
export function collectHtmlAssetRefs(html, htmlFilePath) {
|
|
41
|
+
const root = parse5.parse(html);
|
|
42
|
+
const baseDir = path.dirname(path.resolve(htmlFilePath));
|
|
43
|
+
const refs = [];
|
|
44
|
+
visit(root, refs);
|
|
45
|
+
const seen = new Set();
|
|
46
|
+
const assets = [];
|
|
47
|
+
for (const ref of refs) {
|
|
48
|
+
const resolved = resolveLocalAssetRef(ref, baseDir);
|
|
49
|
+
if (resolved === null)
|
|
50
|
+
continue;
|
|
51
|
+
if (seen.has(resolved.assetPath))
|
|
52
|
+
continue;
|
|
53
|
+
seen.add(resolved.assetPath);
|
|
54
|
+
assets.push(resolved);
|
|
55
|
+
}
|
|
56
|
+
return assets;
|
|
57
|
+
}
|
|
58
|
+
function visit(node, refs) {
|
|
59
|
+
const tagName = node.tagName?.toLowerCase();
|
|
60
|
+
if (tagName !== undefined)
|
|
61
|
+
collectRefsForNode(tagName, node, refs);
|
|
62
|
+
for (const child of node.childNodes ?? [])
|
|
63
|
+
visit(child, refs);
|
|
64
|
+
}
|
|
65
|
+
function collectRefsForNode(tagName, node, refs) {
|
|
66
|
+
if (tagName === "base" && attr(node, "href") !== null) {
|
|
67
|
+
throw new Error("unsupported <base href>: tot resolves support files relative to the HTML file");
|
|
68
|
+
}
|
|
69
|
+
if (tagName === "img") {
|
|
70
|
+
pushAttr(node, "src", refs);
|
|
71
|
+
pushSrcset(node, refs);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (tagName === "source") {
|
|
75
|
+
pushAttr(node, "src", refs);
|
|
76
|
+
pushSrcset(node, refs);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (tagName === "video") {
|
|
80
|
+
pushAttr(node, "src", refs);
|
|
81
|
+
pushAttr(node, "poster", refs);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (tagName === "script") {
|
|
85
|
+
pushAttr(node, "src", refs);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (tagName === "link" && isSupportLink(node)) {
|
|
89
|
+
pushAttr(node, "href", refs);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function pushAttr(node, name, refs) {
|
|
93
|
+
const value = attr(node, name);
|
|
94
|
+
if (value !== null)
|
|
95
|
+
refs.push(value);
|
|
96
|
+
}
|
|
97
|
+
function pushSrcset(node, refs) {
|
|
98
|
+
const value = attr(node, "srcset");
|
|
99
|
+
if (value === null)
|
|
100
|
+
return;
|
|
101
|
+
for (const candidate of parseSrcsetUrls(value))
|
|
102
|
+
refs.push(candidate);
|
|
103
|
+
}
|
|
104
|
+
function attr(node, name) {
|
|
105
|
+
const target = name.toLowerCase();
|
|
106
|
+
for (const a of node.attrs ?? []) {
|
|
107
|
+
if (a.name.toLowerCase() === target)
|
|
108
|
+
return a.value.trim();
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
function isSupportLink(node) {
|
|
113
|
+
const rel = attr(node, "rel");
|
|
114
|
+
if (rel === null)
|
|
115
|
+
return false;
|
|
116
|
+
const tokens = new Set(rel.toLowerCase().split(/\s+/).filter(Boolean));
|
|
117
|
+
return (tokens.has("stylesheet") ||
|
|
118
|
+
tokens.has("preload") ||
|
|
119
|
+
tokens.has("modulepreload") ||
|
|
120
|
+
tokens.has("icon") ||
|
|
121
|
+
tokens.has("apple-touch-icon") ||
|
|
122
|
+
tokens.has("mask-icon"));
|
|
123
|
+
}
|
|
124
|
+
function parseSrcsetUrls(srcset) {
|
|
125
|
+
const urls = [];
|
|
126
|
+
let i = 0;
|
|
127
|
+
while (i < srcset.length) {
|
|
128
|
+
while (i < srcset.length && /[\s,]/u.test(srcset[i]))
|
|
129
|
+
i++;
|
|
130
|
+
const start = i;
|
|
131
|
+
while (i < srcset.length && !/\s/u.test(srcset[i]) && srcset[i] !== ",")
|
|
132
|
+
i++;
|
|
133
|
+
// data: URLs can contain commas; keep reading until the descriptor
|
|
134
|
+
// whitespace. The URL-level scheme skip will discard it later.
|
|
135
|
+
if (srcset.slice(start, i).toLowerCase().startsWith("data:")) {
|
|
136
|
+
while (i < srcset.length && !/\s/u.test(srcset[i]))
|
|
137
|
+
i++;
|
|
138
|
+
}
|
|
139
|
+
const url = srcset.slice(start, i);
|
|
140
|
+
if (url !== "")
|
|
141
|
+
urls.push(url);
|
|
142
|
+
while (i < srcset.length && srcset[i] !== ",")
|
|
143
|
+
i++;
|
|
144
|
+
}
|
|
145
|
+
return urls;
|
|
146
|
+
}
|
|
147
|
+
function resolveLocalAssetRef(ref, baseDir) {
|
|
148
|
+
const stripped = stripQueryAndHash(ref.trim());
|
|
149
|
+
if (stripped === null)
|
|
150
|
+
return null;
|
|
151
|
+
const decoded = decodePath(stripped);
|
|
152
|
+
const normalized = decoded.replace(/\\/g, "/");
|
|
153
|
+
const skip = skipReason(normalized);
|
|
154
|
+
if (skip === "external")
|
|
155
|
+
return null;
|
|
156
|
+
if (skip === "root-relative") {
|
|
157
|
+
throw new Error(`root-relative asset ref is unsupported: ${ref} (use a relative path)`);
|
|
158
|
+
}
|
|
159
|
+
const assetPath = path.posix.normalize(normalized);
|
|
160
|
+
if (!validWorkspacePath(assetPath)) {
|
|
161
|
+
throw new Error(`unsupported local asset ref: ${ref}`);
|
|
162
|
+
}
|
|
163
|
+
const contentType = contentTypeForAssetPath(assetPath);
|
|
164
|
+
if (contentType === null) {
|
|
165
|
+
throw new Error(`unsupported asset type for local ref: ${ref}`);
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
ref,
|
|
169
|
+
assetPath,
|
|
170
|
+
localPath: path.resolve(baseDir, assetPath),
|
|
171
|
+
contentType,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
function stripQueryAndHash(ref) {
|
|
175
|
+
if (ref === "" || ref.startsWith("#"))
|
|
176
|
+
return null;
|
|
177
|
+
const splitAt = firstIndexOf(ref, ["?", "#"]);
|
|
178
|
+
return splitAt < 0 ? ref : ref.slice(0, splitAt);
|
|
179
|
+
}
|
|
180
|
+
function firstIndexOf(value, needles) {
|
|
181
|
+
let idx = -1;
|
|
182
|
+
for (const needle of needles) {
|
|
183
|
+
const found = value.indexOf(needle);
|
|
184
|
+
if (found >= 0 && (idx < 0 || found < idx))
|
|
185
|
+
idx = found;
|
|
186
|
+
}
|
|
187
|
+
return idx;
|
|
188
|
+
}
|
|
189
|
+
function decodePath(ref) {
|
|
190
|
+
try {
|
|
191
|
+
return decodeURIComponent(ref);
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
throw new Error(`invalid percent-encoding in local asset ref: ${ref}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
function skipReason(ref) {
|
|
198
|
+
if (ref === "" || ref.startsWith("//"))
|
|
199
|
+
return "external";
|
|
200
|
+
if (ref.startsWith("/"))
|
|
201
|
+
return "root-relative";
|
|
202
|
+
const schemeMatch = /^[a-z][a-z0-9+.-]*:/i.exec(ref);
|
|
203
|
+
return schemeMatch !== null ? "external" : null;
|
|
204
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { listCommand, loginCommand, publishCommand, removeCommand, updateCommand, } from "./commands.js";
|
|
3
|
+
import { Config } from "./config.js";
|
|
4
|
+
import { createHttpClient } from "./http.js";
|
|
5
|
+
function parseArgs(argv) {
|
|
6
|
+
const out = { _: [], flags: {} };
|
|
7
|
+
for (let i = 0; i < argv.length; i++) {
|
|
8
|
+
const a = argv[i];
|
|
9
|
+
if (a.startsWith("--")) {
|
|
10
|
+
const key = a.slice(2);
|
|
11
|
+
const next = argv[i + 1];
|
|
12
|
+
if (next === undefined || next.startsWith("--")) {
|
|
13
|
+
out.flags[key] = true;
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
out.flags[key] = next;
|
|
17
|
+
i++;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
out._.push(a);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
function flagStr(flags, name) {
|
|
27
|
+
const v = flags[name];
|
|
28
|
+
return typeof v === "string" ? v : undefined;
|
|
29
|
+
}
|
|
30
|
+
const HELP = `tot — publish a page to tot.page
|
|
31
|
+
|
|
32
|
+
tot <file> publish a raw markdown or html file
|
|
33
|
+
tot update <file|url> push new content to the same living URL
|
|
34
|
+
tot remove <file|url> delete a page (anyone with the link can; so can you)
|
|
35
|
+
tot list list pages you've published from this machine
|
|
36
|
+
tot login --key <KEY> save a pre-minted wsk_live_ key to ~/.tot (optional)
|
|
37
|
+
|
|
38
|
+
flags
|
|
39
|
+
--endpoint <url> override the API origin (default https://api.tot.page)
|
|
40
|
+
--key <KEY> API key for this run (login persists it)
|
|
41
|
+
--help show this help`;
|
|
42
|
+
function makeDeps(cfg) {
|
|
43
|
+
return {
|
|
44
|
+
http: createHttpClient(cfg),
|
|
45
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
46
|
+
now: () => Date.now(),
|
|
47
|
+
log: (msg) => console.log(msg),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
51
|
+
const args = parseArgs(argv);
|
|
52
|
+
const cfg = Config.load();
|
|
53
|
+
if (flagStr(args.flags, "endpoint")) {
|
|
54
|
+
cfg.endpoint = flagStr(args.flags, "endpoint");
|
|
55
|
+
}
|
|
56
|
+
if (flagStr(args.flags, "key")) {
|
|
57
|
+
cfg.key = flagStr(args.flags, "key");
|
|
58
|
+
}
|
|
59
|
+
const cmd = args._[0];
|
|
60
|
+
if (!cmd || cmd === "help" || args.flags.help === true) {
|
|
61
|
+
console.log(HELP);
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
const deps = makeDeps(cfg);
|
|
65
|
+
if (cmd === "login") {
|
|
66
|
+
const key = flagStr(args.flags, "key") ?? cfg.key ?? undefined;
|
|
67
|
+
if (!key) {
|
|
68
|
+
console.error("provide a key: tot login --key <KEY>");
|
|
69
|
+
return 1;
|
|
70
|
+
}
|
|
71
|
+
await loginCommand(key, cfg, deps);
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
if (cmd === "list") {
|
|
75
|
+
listCommand(cfg, deps);
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
if (cmd === "update") {
|
|
79
|
+
const target = args._[1];
|
|
80
|
+
if (!target) {
|
|
81
|
+
console.error("usage: tot update <file|url>");
|
|
82
|
+
return 1;
|
|
83
|
+
}
|
|
84
|
+
await updateCommand(target, cfg, deps);
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
if (cmd === "remove" || cmd === "rm" || cmd === "delete") {
|
|
88
|
+
const target = args._[1];
|
|
89
|
+
if (!target) {
|
|
90
|
+
console.error("usage: tot remove <file|url>");
|
|
91
|
+
return 1;
|
|
92
|
+
}
|
|
93
|
+
await removeCommand(target, cfg, deps);
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
96
|
+
// Default: the first positional is a file to publish.
|
|
97
|
+
await publishCommand(cmd, cfg, deps);
|
|
98
|
+
return 0;
|
|
99
|
+
}
|
|
100
|
+
// Only run when invoked as the CLI, not when imported (e.g. by tests).
|
|
101
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
102
|
+
main().then((code) => process.exit(code), (err) => {
|
|
103
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
104
|
+
console.error("error:", msg);
|
|
105
|
+
process.exit(1);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Config } from "./config.js";
|
|
2
|
+
import { type HttpClient } from "./http.js";
|
|
3
|
+
/** Injected side-effects, so commands stay testable (no real clock, no console). */
|
|
4
|
+
export interface CommandDeps {
|
|
5
|
+
http: HttpClient;
|
|
6
|
+
/** Resolves after `ms` — overridable in tests to make polling instant. */
|
|
7
|
+
sleep: (ms: number) => Promise<void>;
|
|
8
|
+
/** Monotonic clock in ms — overridable in tests to drive the timeout. */
|
|
9
|
+
now: () => number;
|
|
10
|
+
log: (msg: string) => void;
|
|
11
|
+
}
|
|
12
|
+
export declare const POLL_INTERVAL_MS = 500;
|
|
13
|
+
export declare const POLL_TIMEOUT_MS = 30000;
|
|
14
|
+
export interface PublishOpts {
|
|
15
|
+
/** Override the detected kind (rarely needed; extension wins by default). */
|
|
16
|
+
kind?: "markdown" | "html";
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Build the living URL. `index.md`/`index.html` resolve to the bare workspace
|
|
20
|
+
* (mirrors `/s/{slug}`); anything else appends the doc path.
|
|
21
|
+
*/
|
|
22
|
+
export declare function livingUrl(contentOrigin: string, slug: string, docPath: string): string;
|
|
23
|
+
/** Build the immutable, commit-pinned URL for one exact file version. */
|
|
24
|
+
export declare function frozenUrl(contentOrigin: string, slug: string, docPath: string, version: string): string;
|
|
25
|
+
/**
|
|
26
|
+
* `tot <file>` — publish a new page.
|
|
27
|
+
* POST /v1/documents → poll GET until `version` is non-null → print living URL.
|
|
28
|
+
*/
|
|
29
|
+
export declare function publishCommand(file: string, cfg: Config, deps: CommandDeps, opts?: PublishOpts): Promise<void>;
|
|
30
|
+
/**
|
|
31
|
+
* `tot update <file|url>` — push new content under the same living URL.
|
|
32
|
+
* Resolves {wsId, docId} from the local registry, then PUTs the raw body.
|
|
33
|
+
*/
|
|
34
|
+
export declare function updateCommand(target: string, cfg: Config, deps: CommandDeps): Promise<void>;
|
|
35
|
+
/**
|
|
36
|
+
* `tot remove <file|url|slug>` — hard-delete the page and prune the registry.
|
|
37
|
+
*/
|
|
38
|
+
export declare function removeCommand(target: string, cfg: Config, deps: CommandDeps): Promise<void>;
|
|
39
|
+
/** `tot list` — purely local; print the pages published from this machine. */
|
|
40
|
+
export declare function listCommand(cfg: Config, deps: CommandDeps): void;
|
|
41
|
+
/** `tot login --key <KEY>` — store a pre-minted key and verify it via /v1/me. */
|
|
42
|
+
export declare function loginCommand(key: string, cfg: Config, deps: CommandDeps): Promise<void>;
|
package/dist/commands.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { collectHtmlAssetRefs, encodeWorkspacePath, MAX_ASSET_BYTES, MAX_ASSET_BYTES_LABEL, validWorkspacePath, } from "./asset-refs.js";
|
|
5
|
+
import { deleteDocument, getDocument, getMe, postDocument, postWorkspace, postWorkspaceDocument, putAsset, putDocument, } from "./http.js";
|
|
6
|
+
export const POLL_INTERVAL_MS = 500;
|
|
7
|
+
export const POLL_TIMEOUT_MS = 30_000;
|
|
8
|
+
function detectKind(file) {
|
|
9
|
+
const ext = path.extname(file).toLowerCase();
|
|
10
|
+
if (ext === ".html" || ext === ".htm")
|
|
11
|
+
return "html";
|
|
12
|
+
return "markdown";
|
|
13
|
+
}
|
|
14
|
+
function contentTypeFor(kind) {
|
|
15
|
+
return kind === "html" ? "text/html" : "text/markdown";
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Build the living URL. `index.md`/`index.html` resolve to the bare workspace
|
|
19
|
+
* (mirrors `/s/{slug}`); anything else appends the doc path.
|
|
20
|
+
*/
|
|
21
|
+
export function livingUrl(contentOrigin, slug, docPath) {
|
|
22
|
+
const base = contentOrigin.replace(/\/+$/, "");
|
|
23
|
+
if (docPath === "index.md" || docPath === "index.html") {
|
|
24
|
+
return `${base}/${slug}`;
|
|
25
|
+
}
|
|
26
|
+
return `${base}/${slug}/${encodeWorkspacePath(docPath)}`;
|
|
27
|
+
}
|
|
28
|
+
/** Build the immutable, commit-pinned URL for one exact file version. */
|
|
29
|
+
export function frozenUrl(contentOrigin, slug, docPath, version) {
|
|
30
|
+
const base = contentOrigin.replace(/\/+$/, "");
|
|
31
|
+
return `${base}/${slug}/${encodeWorkspacePath(docPath)}@${version}`;
|
|
32
|
+
}
|
|
33
|
+
function shortCommit(version) {
|
|
34
|
+
return version.slice(0, 7);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* `tot <file>` — publish a new page.
|
|
38
|
+
* POST /v1/documents → poll GET until `version` is non-null → print living URL.
|
|
39
|
+
*/
|
|
40
|
+
export async function publishCommand(file, cfg, deps, opts = {}) {
|
|
41
|
+
// Catches the no-network-on-missing-file contract: this throws before any
|
|
42
|
+
// HTTP call, so a typo never hits the server.
|
|
43
|
+
if (!fs.existsSync(file)) {
|
|
44
|
+
throw new Error(`file not found: ${file}`);
|
|
45
|
+
}
|
|
46
|
+
const body = fs.readFileSync(file, "utf8");
|
|
47
|
+
const kind = opts.kind ?? detectKind(file);
|
|
48
|
+
const assetRefs = collectAssetsForPublish(kind, body, file);
|
|
49
|
+
const targetDocPath = assetRefs.length > 0 ? publishDocPath(file) : null;
|
|
50
|
+
const assets = prepareAssetRefs(assetRefs);
|
|
51
|
+
let wsId;
|
|
52
|
+
let docId;
|
|
53
|
+
let slug;
|
|
54
|
+
let docPath;
|
|
55
|
+
let version;
|
|
56
|
+
let fileUrl;
|
|
57
|
+
if (assetRefs.length === 0) {
|
|
58
|
+
const created = await postDocument(deps.http, kind, body);
|
|
59
|
+
wsId = created.workspace.id;
|
|
60
|
+
docId = created.document.id;
|
|
61
|
+
slug = created.workspace.slug;
|
|
62
|
+
docPath = created.document.doc_path;
|
|
63
|
+
version = created.document.version;
|
|
64
|
+
fileUrl = created.document.file_url ?? null;
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
if (targetDocPath === null) {
|
|
68
|
+
throw new Error("internal error: missing target document path for HTML assets publish");
|
|
69
|
+
}
|
|
70
|
+
const workspace = await postWorkspace(deps.http);
|
|
71
|
+
wsId = workspace.id;
|
|
72
|
+
slug = workspace.slug;
|
|
73
|
+
await uploadAssetRefs(deps.http, wsId, assets);
|
|
74
|
+
const document = await postWorkspaceDocument(deps.http, wsId, targetDocPath, kind, body);
|
|
75
|
+
docId = document.id;
|
|
76
|
+
docPath = document.doc_path;
|
|
77
|
+
version = document.version;
|
|
78
|
+
fileUrl = document.file_url ?? null;
|
|
79
|
+
}
|
|
80
|
+
// Poll until the first save lands (version flips from null). Bounded by a
|
|
81
|
+
// 30s timeout so a stuck publish fails loudly instead of hanging forever.
|
|
82
|
+
const start = deps.now();
|
|
83
|
+
// Sequential by design: each poll waits for the previous to settle (the loop
|
|
84
|
+
// IS the wait), so the await-in-loop warning doesn't apply here.
|
|
85
|
+
// oxlint-disable no-await-in-loop
|
|
86
|
+
while (version === null) {
|
|
87
|
+
if (deps.now() - start > POLL_TIMEOUT_MS) {
|
|
88
|
+
throw new Error("document failed to publish; version is still null after 30s");
|
|
89
|
+
}
|
|
90
|
+
await deps.sleep(POLL_INTERVAL_MS);
|
|
91
|
+
const doc = await getDocument(deps.http, wsId, docId);
|
|
92
|
+
version = doc.version;
|
|
93
|
+
docPath = doc.doc_path;
|
|
94
|
+
fileUrl = doc.file_url ?? fileUrl;
|
|
95
|
+
}
|
|
96
|
+
// oxlint-enable no-await-in-loop
|
|
97
|
+
const url = livingUrl(cfg.contentOrigin, slug, docPath);
|
|
98
|
+
const snapshotUrl = fileUrl ?? frozenUrl(cfg.contentOrigin, slug, docPath, version);
|
|
99
|
+
const entry = {
|
|
100
|
+
wsId,
|
|
101
|
+
docId,
|
|
102
|
+
slug,
|
|
103
|
+
url,
|
|
104
|
+
kind,
|
|
105
|
+
docPath,
|
|
106
|
+
bytes: Buffer.byteLength(body, "utf8"),
|
|
107
|
+
assets: registryAssets(assets),
|
|
108
|
+
createdAt: new Date().toISOString(),
|
|
109
|
+
};
|
|
110
|
+
cfg.addEntry(file, entry);
|
|
111
|
+
cfg.save();
|
|
112
|
+
deps.log("");
|
|
113
|
+
deps.log(` ↳ ${url}`);
|
|
114
|
+
deps.log(` commit ${shortCommit(version)}`);
|
|
115
|
+
deps.log(` frozen ${snapshotUrl}`);
|
|
116
|
+
deps.log("");
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* `tot update <file|url>` — push new content under the same living URL.
|
|
120
|
+
* Resolves {wsId, docId} from the local registry, then PUTs the raw body.
|
|
121
|
+
*/
|
|
122
|
+
export async function updateCommand(target, cfg, deps) {
|
|
123
|
+
const resolved = cfg.resolve(target);
|
|
124
|
+
if (!resolved) {
|
|
125
|
+
throw new Error(`not in your registry: ${target} (publish it first, or run 'tot list')`);
|
|
126
|
+
}
|
|
127
|
+
const { entry } = resolved;
|
|
128
|
+
// Update always reads new content from a LOCAL file. When the target is a
|
|
129
|
+
// slug/url we use the file path recorded at publish time. The registry may
|
|
130
|
+
// not hold a usable local path (the entry was keyed by slug, or the file was
|
|
131
|
+
// moved/renamed/deleted since publish). In that case, emit a message that
|
|
132
|
+
// names a local file as the requirement — NOT a misleading
|
|
133
|
+
// "file not found: https://…" / "file not found: <slug>" that implies the
|
|
134
|
+
// URL or slug itself is a path.
|
|
135
|
+
const file = resolved.file;
|
|
136
|
+
if (file === null || !fs.existsSync(file)) {
|
|
137
|
+
const where = file !== null && file !== target ? ` (recorded source '${file}')` : "";
|
|
138
|
+
throw new Error(`tot update needs a local file: '${target}' resolves to a published page but its source` +
|
|
139
|
+
`${where} is missing — pass the path to the current content`);
|
|
140
|
+
}
|
|
141
|
+
const body = fs.readFileSync(file, "utf8");
|
|
142
|
+
const assetRefs = collectAssetsForPublish(entry.kind, body, file);
|
|
143
|
+
const assets = prepareAssetRefs(assetRefs);
|
|
144
|
+
await uploadAssetRefs(deps.http, entry.wsId, assets.filter((asset) => assetNeedsUpload(asset, entry.assets?.[asset.assetPath])));
|
|
145
|
+
const doc = await putDocument(deps.http, entry.wsId, entry.docId, body, contentTypeFor(entry.kind));
|
|
146
|
+
entry.bytes = Buffer.byteLength(body, "utf8");
|
|
147
|
+
entry.assets = registryAssets(assets);
|
|
148
|
+
if (doc.doc_path)
|
|
149
|
+
entry.docPath = doc.doc_path;
|
|
150
|
+
cfg.save();
|
|
151
|
+
deps.log("");
|
|
152
|
+
deps.log(` updated ${entry.url}`);
|
|
153
|
+
if (doc.version) {
|
|
154
|
+
deps.log(` commit ${shortCommit(doc.version)}`);
|
|
155
|
+
deps.log(` frozen ${doc.file_url ?? frozenUrl(cfg.contentOrigin, entry.slug, entry.docPath, doc.version)}`);
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
deps.log(" commit (pending — first save still landing)");
|
|
159
|
+
}
|
|
160
|
+
deps.log("");
|
|
161
|
+
}
|
|
162
|
+
function collectAssetsForPublish(kind, body, file) {
|
|
163
|
+
return kind === "html" ? collectHtmlAssetRefs(body, file) : [];
|
|
164
|
+
}
|
|
165
|
+
function prepareAssetRefs(refs) {
|
|
166
|
+
const out = [];
|
|
167
|
+
for (const ref of refs) {
|
|
168
|
+
if (!fs.existsSync(ref.localPath)) {
|
|
169
|
+
throw new Error(`local asset not found: ${ref.ref} (${ref.localPath})`);
|
|
170
|
+
}
|
|
171
|
+
const stat = fs.statSync(ref.localPath);
|
|
172
|
+
if (!stat.isFile()) {
|
|
173
|
+
throw new Error(`local asset is not a file: ${ref.ref} (${ref.localPath})`);
|
|
174
|
+
}
|
|
175
|
+
if (stat.size > MAX_ASSET_BYTES) {
|
|
176
|
+
throw new Error(`local asset too large: ${ref.ref} is ${stat.size} bytes; max is ${MAX_ASSET_BYTES} bytes (${MAX_ASSET_BYTES_LABEL})`);
|
|
177
|
+
}
|
|
178
|
+
const bytes = fs.readFileSync(ref.localPath);
|
|
179
|
+
if (bytes.byteLength > MAX_ASSET_BYTES) {
|
|
180
|
+
throw new Error(`local asset too large: ${ref.ref} is ${bytes.byteLength} bytes; max is ${MAX_ASSET_BYTES} bytes (${MAX_ASSET_BYTES_LABEL})`);
|
|
181
|
+
}
|
|
182
|
+
out.push({
|
|
183
|
+
...ref,
|
|
184
|
+
bytes,
|
|
185
|
+
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
186
|
+
size: bytes.byteLength,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
async function uploadAssetRefs(http, wsId, refs) {
|
|
192
|
+
await Promise.all(refs.map((ref) => putAsset(http, wsId, ref.assetPath, ref.bytes, ref.contentType)));
|
|
193
|
+
}
|
|
194
|
+
function publishDocPath(file) {
|
|
195
|
+
const docPath = path.basename(file);
|
|
196
|
+
if (!validWorkspacePath(docPath)) {
|
|
197
|
+
throw new Error(`unsupported document path: ${docPath} (no leading/trailing slash, '.', '..', control chars, or \\ ? # %)`);
|
|
198
|
+
}
|
|
199
|
+
return docPath;
|
|
200
|
+
}
|
|
201
|
+
function assetNeedsUpload(asset, known) {
|
|
202
|
+
return (known === undefined ||
|
|
203
|
+
known.sha256 !== asset.sha256 ||
|
|
204
|
+
known.contentType !== asset.contentType ||
|
|
205
|
+
known.size !== asset.size);
|
|
206
|
+
}
|
|
207
|
+
function registryAssets(refs) {
|
|
208
|
+
if (refs.length === 0)
|
|
209
|
+
return undefined;
|
|
210
|
+
return Object.fromEntries(refs.map((ref) => [
|
|
211
|
+
ref.assetPath,
|
|
212
|
+
{ sha256: ref.sha256, contentType: ref.contentType, size: ref.size },
|
|
213
|
+
]));
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* `tot remove <file|url|slug>` — hard-delete the page and prune the registry.
|
|
217
|
+
*/
|
|
218
|
+
export async function removeCommand(target, cfg, deps) {
|
|
219
|
+
const resolved = cfg.resolve(target);
|
|
220
|
+
if (!resolved) {
|
|
221
|
+
throw new Error(`not in your registry: ${target} (run 'tot list')`);
|
|
222
|
+
}
|
|
223
|
+
const { entry } = resolved;
|
|
224
|
+
await deleteDocument(deps.http, entry.wsId, entry.docId);
|
|
225
|
+
cfg.removeEntry(entry.wsId, entry.docId);
|
|
226
|
+
cfg.save();
|
|
227
|
+
deps.log(`removed ${entry.url}`);
|
|
228
|
+
}
|
|
229
|
+
/** `tot list` — purely local; print the pages published from this machine. */
|
|
230
|
+
export function listCommand(cfg, deps) {
|
|
231
|
+
const entries = Object.entries(cfg.registry);
|
|
232
|
+
if (entries.length === 0) {
|
|
233
|
+
deps.log("no pages — publish one with: tot <file>");
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
deps.log("");
|
|
237
|
+
for (const [file, entry] of entries) {
|
|
238
|
+
deps.log(` ${entry.url}`);
|
|
239
|
+
deps.log(` file=${file} slug=${entry.slug} ${entry.bytes}b ${entry.kind} ${entry.createdAt.slice(0, 19)}`);
|
|
240
|
+
deps.log("");
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
/** `tot login --key <KEY>` — store a pre-minted key and verify it via /v1/me. */
|
|
244
|
+
export async function loginCommand(key, cfg, deps) {
|
|
245
|
+
cfg.key = key;
|
|
246
|
+
// Verify the key works before persisting it (a typo'd key gets a 401 here).
|
|
247
|
+
const me = await getMe(deps.http);
|
|
248
|
+
cfg.save();
|
|
249
|
+
deps.log(`logged in as ${me.email ?? me.user_id}`);
|
|
250
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/** The /v1 API origin the CLI talks to. A branded alias (api.tot.page) so the
|
|
2
|
+
* published package is decoupled from infra — repoint the DNS (staging → prod)
|
|
3
|
+
* without republishing the CLI. */
|
|
4
|
+
export declare const DEFAULT_ENDPOINT = "https://api.tot.page";
|
|
5
|
+
/** The public content origin where living pages are served (the link you share). */
|
|
6
|
+
export declare const DEFAULT_CONTENT_ORIGIN = "https://tot.page";
|
|
7
|
+
export interface RegistryEntry {
|
|
8
|
+
wsId: string;
|
|
9
|
+
docId: string;
|
|
10
|
+
slug: string;
|
|
11
|
+
url: string;
|
|
12
|
+
kind: "markdown" | "html";
|
|
13
|
+
docPath: string;
|
|
14
|
+
bytes: number;
|
|
15
|
+
assets?: Record<string, RegistryAssetEntry>;
|
|
16
|
+
createdAt: string;
|
|
17
|
+
}
|
|
18
|
+
export interface RegistryAssetEntry {
|
|
19
|
+
sha256: string;
|
|
20
|
+
contentType: string;
|
|
21
|
+
size: number;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The on-disk `~/.tot` state: the API endpoint, an optional key, and a local
|
|
25
|
+
* registry of pages published from this machine. Anonymous pages have no
|
|
26
|
+
* server-side owner, so this registry is the only record of what you published
|
|
27
|
+
* (SPEC §2.6 — "visited-by-link is never listable").
|
|
28
|
+
*/
|
|
29
|
+
export declare class Config {
|
|
30
|
+
endpoint: string;
|
|
31
|
+
contentOrigin: string;
|
|
32
|
+
key: string | null;
|
|
33
|
+
registry: Record<string, RegistryEntry>;
|
|
34
|
+
private readonly file;
|
|
35
|
+
private constructor();
|
|
36
|
+
/**
|
|
37
|
+
* Load from disk. A missing file yields defaults. A file that EXISTS but fails
|
|
38
|
+
* to parse is NOT silently treated as empty — that would let the next save()
|
|
39
|
+
* overwrite (and permanently destroy) the only record of every anonymous page
|
|
40
|
+
* published from this machine (SPEC §2.6: pages have no server-side listing).
|
|
41
|
+
* Instead the corrupt bytes are preserved by renaming the file aside, and we
|
|
42
|
+
* continue from defaults so the CLI still works going forward.
|
|
43
|
+
*/
|
|
44
|
+
static load(): Config;
|
|
45
|
+
/**
|
|
46
|
+
* Persist to `~/.tot` with owner-only perms (it may hold an API key).
|
|
47
|
+
* Writes atomically — to a temp file in the same dir, then rename over the
|
|
48
|
+
* target — so an interrupted write can never half-truncate the registry (the
|
|
49
|
+
* sole record of anonymous pages).
|
|
50
|
+
*/
|
|
51
|
+
save(): void;
|
|
52
|
+
/** Record a freshly-published page, keyed by its local file path. */
|
|
53
|
+
addEntry(file: string, entry: RegistryEntry): void;
|
|
54
|
+
getEntryByFile(file: string): RegistryEntry | null;
|
|
55
|
+
/** Find an entry by slug or by full living URL (for `update`/`remove <url>`). */
|
|
56
|
+
getEntryBySlug(slugOrUrl: string): RegistryEntry | null;
|
|
57
|
+
/** Resolve a target that may be a file path, a slug, or a living URL. */
|
|
58
|
+
resolve(target: string): {
|
|
59
|
+
file: string | null;
|
|
60
|
+
entry: RegistryEntry;
|
|
61
|
+
} | null;
|
|
62
|
+
/** Remove every registry key pointing at this (wsId, docId) pair. */
|
|
63
|
+
removeEntry(wsId: string, docId: string): void;
|
|
64
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
/** The /v1 API origin the CLI talks to. A branded alias (api.tot.page) so the
|
|
5
|
+
* published package is decoupled from infra — repoint the DNS (staging → prod)
|
|
6
|
+
* without republishing the CLI. */
|
|
7
|
+
export const DEFAULT_ENDPOINT = "https://api.tot.page";
|
|
8
|
+
/** The public content origin where living pages are served (the link you share). */
|
|
9
|
+
export const DEFAULT_CONTENT_ORIGIN = "https://tot.page";
|
|
10
|
+
function configPath() {
|
|
11
|
+
// TOT_CONFIG lets tests point at a temp file instead of the real ~/.tot.
|
|
12
|
+
return process.env.TOT_CONFIG ?? path.join(os.homedir(), ".tot");
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The on-disk `~/.tot` state: the API endpoint, an optional key, and a local
|
|
16
|
+
* registry of pages published from this machine. Anonymous pages have no
|
|
17
|
+
* server-side owner, so this registry is the only record of what you published
|
|
18
|
+
* (SPEC §2.6 — "visited-by-link is never listable").
|
|
19
|
+
*/
|
|
20
|
+
export class Config {
|
|
21
|
+
endpoint;
|
|
22
|
+
contentOrigin;
|
|
23
|
+
key;
|
|
24
|
+
registry;
|
|
25
|
+
file;
|
|
26
|
+
constructor(file, data) {
|
|
27
|
+
this.file = file;
|
|
28
|
+
this.endpoint = data.endpoint;
|
|
29
|
+
this.contentOrigin = data.contentOrigin;
|
|
30
|
+
this.key = data.key;
|
|
31
|
+
this.registry = data.registry;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Load from disk. A missing file yields defaults. A file that EXISTS but fails
|
|
35
|
+
* to parse is NOT silently treated as empty — that would let the next save()
|
|
36
|
+
* overwrite (and permanently destroy) the only record of every anonymous page
|
|
37
|
+
* published from this machine (SPEC §2.6: pages have no server-side listing).
|
|
38
|
+
* Instead the corrupt bytes are preserved by renaming the file aside, and we
|
|
39
|
+
* continue from defaults so the CLI still works going forward.
|
|
40
|
+
*/
|
|
41
|
+
static load() {
|
|
42
|
+
const file = configPath();
|
|
43
|
+
let parsed = {};
|
|
44
|
+
let raw = null;
|
|
45
|
+
try {
|
|
46
|
+
raw = fs.readFileSync(file, "utf8");
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// File is missing (or unreadable) — start from defaults, nothing to preserve.
|
|
50
|
+
raw = null;
|
|
51
|
+
}
|
|
52
|
+
if (raw !== null) {
|
|
53
|
+
try {
|
|
54
|
+
parsed = JSON.parse(raw);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// The file exists but is corrupt/half-written. Preserve its bytes by
|
|
58
|
+
// renaming aside BEFORE any later save() can clobber them, so the user
|
|
59
|
+
// can recover their page list. Then continue from defaults.
|
|
60
|
+
const backup = `${file}.corrupt.${Date.now()}`;
|
|
61
|
+
try {
|
|
62
|
+
fs.renameSync(file, backup);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// best-effort — if even the rename fails, we still avoid a silent wipe
|
|
66
|
+
// because we never reach here without having tried to preserve the file.
|
|
67
|
+
}
|
|
68
|
+
parsed = {};
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return new Config(file, {
|
|
72
|
+
endpoint: parsed.endpoint ?? DEFAULT_ENDPOINT,
|
|
73
|
+
contentOrigin: parsed.contentOrigin ?? DEFAULT_CONTENT_ORIGIN,
|
|
74
|
+
key: parsed.key ?? null,
|
|
75
|
+
registry: parsed.registry ?? {},
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Persist to `~/.tot` with owner-only perms (it may hold an API key).
|
|
80
|
+
* Writes atomically — to a temp file in the same dir, then rename over the
|
|
81
|
+
* target — so an interrupted write can never half-truncate the registry (the
|
|
82
|
+
* sole record of anonymous pages).
|
|
83
|
+
*/
|
|
84
|
+
save() {
|
|
85
|
+
const data = {
|
|
86
|
+
endpoint: this.endpoint,
|
|
87
|
+
contentOrigin: this.contentOrigin,
|
|
88
|
+
key: this.key,
|
|
89
|
+
registry: this.registry,
|
|
90
|
+
};
|
|
91
|
+
const tmp = `${this.file}.tmp.${process.pid}.${Date.now()}`;
|
|
92
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
93
|
+
try {
|
|
94
|
+
// writeFileSync's `mode` only applies on create; chmod covers an existing temp.
|
|
95
|
+
fs.chmodSync(tmp, 0o600);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// best-effort (e.g. Windows) — not fatal.
|
|
99
|
+
}
|
|
100
|
+
// rename is atomic on the same filesystem: readers see either the old file
|
|
101
|
+
// or the fully-written new one, never a partial.
|
|
102
|
+
fs.renameSync(tmp, this.file);
|
|
103
|
+
}
|
|
104
|
+
/** Record a freshly-published page, keyed by its local file path. */
|
|
105
|
+
addEntry(file, entry) {
|
|
106
|
+
this.registry[file] = entry;
|
|
107
|
+
}
|
|
108
|
+
getEntryByFile(file) {
|
|
109
|
+
return this.registry[file] ?? null;
|
|
110
|
+
}
|
|
111
|
+
/** Find an entry by slug or by full living URL (for `update`/`remove <url>`). */
|
|
112
|
+
getEntryBySlug(slugOrUrl) {
|
|
113
|
+
for (const entry of Object.values(this.registry)) {
|
|
114
|
+
if (entry.slug === slugOrUrl || entry.url === slugOrUrl)
|
|
115
|
+
return entry;
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
/** Resolve a target that may be a file path, a slug, or a living URL. */
|
|
120
|
+
resolve(target) {
|
|
121
|
+
const byFile = this.getEntryByFile(target);
|
|
122
|
+
if (byFile)
|
|
123
|
+
return { file: target, entry: byFile };
|
|
124
|
+
const bySlug = this.getEntryBySlug(target);
|
|
125
|
+
if (bySlug) {
|
|
126
|
+
// Find the file key that points at this entry, if any.
|
|
127
|
+
for (const [file, entry] of Object.entries(this.registry)) {
|
|
128
|
+
if (entry === bySlug)
|
|
129
|
+
return { file, entry };
|
|
130
|
+
}
|
|
131
|
+
return { file: null, entry: bySlug };
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
/** Remove every registry key pointing at this (wsId, docId) pair. */
|
|
136
|
+
removeEntry(wsId, docId) {
|
|
137
|
+
for (const [file, entry] of Object.entries(this.registry)) {
|
|
138
|
+
if (entry.wsId === wsId && entry.docId === docId) {
|
|
139
|
+
delete this.registry[file];
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
|
|
2
|
+
export type HttpBody = string | Uint8Array | ArrayBuffer;
|
|
3
|
+
export interface HttpResponse {
|
|
4
|
+
status: number;
|
|
5
|
+
/** Parsed JSON body, or null when the body was empty / not JSON. */
|
|
6
|
+
json: any;
|
|
7
|
+
/** Raw response text (kept for error messages and non-JSON bodies). */
|
|
8
|
+
text: string;
|
|
9
|
+
}
|
|
10
|
+
export interface HttpClient {
|
|
11
|
+
fetch(method: HttpMethod, path: string, body?: HttpBody | undefined, headers?: Record<string, string>): Promise<HttpResponse>;
|
|
12
|
+
}
|
|
13
|
+
/** Just the bits of `Config` the HTTP layer needs (keeps this module decoupled). */
|
|
14
|
+
export interface HttpConfig {
|
|
15
|
+
endpoint: string;
|
|
16
|
+
key: string | null;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Wraps the global `fetch` into an `HttpClient`. Adds the bearer key (when set)
|
|
20
|
+
* and a User-Agent, joins the path onto the configured endpoint, and normalizes
|
|
21
|
+
* every response into `{status, json, text}` so callers never re-parse.
|
|
22
|
+
*/
|
|
23
|
+
export declare function createHttpClient(cfg: HttpConfig, fetchImpl?: typeof fetch): HttpClient;
|
|
24
|
+
/** Pulls the clearest error message out of a non-2xx response. */
|
|
25
|
+
export declare function errorMessage(res: HttpResponse): string;
|
|
26
|
+
export interface DocumentEntity {
|
|
27
|
+
id: string;
|
|
28
|
+
workspace_id: string;
|
|
29
|
+
share_url: string;
|
|
30
|
+
doc_path: string;
|
|
31
|
+
kind: "markdown" | "html";
|
|
32
|
+
title: string | null;
|
|
33
|
+
version: string | null;
|
|
34
|
+
body: string;
|
|
35
|
+
created_at: string;
|
|
36
|
+
updated_at: string;
|
|
37
|
+
file_url: string | null;
|
|
38
|
+
}
|
|
39
|
+
export interface WorkspaceEntity {
|
|
40
|
+
id: string;
|
|
41
|
+
slug: string;
|
|
42
|
+
share_url: string;
|
|
43
|
+
visibility: string;
|
|
44
|
+
}
|
|
45
|
+
export interface CreateDocumentResult {
|
|
46
|
+
document: DocumentEntity;
|
|
47
|
+
workspace: WorkspaceEntity;
|
|
48
|
+
}
|
|
49
|
+
export type DocumentKind = "markdown" | "html";
|
|
50
|
+
/** POST /v1/documents — create a new anonymous (open) document. */
|
|
51
|
+
export declare function postDocument(http: HttpClient, kind: DocumentKind, body: string): Promise<CreateDocumentResult>;
|
|
52
|
+
/** POST /v1/workspaces — create an empty workspace shell. */
|
|
53
|
+
export declare function postWorkspace(http: HttpClient): Promise<WorkspaceEntity>;
|
|
54
|
+
/** PUT /v1/workspaces/{wsId}/assets/{assetPath} — upload/replace raw support bytes. */
|
|
55
|
+
export declare function putAsset(http: HttpClient, wsId: string, assetPath: string, body: Uint8Array, contentType: string): Promise<void>;
|
|
56
|
+
/** POST /v1/workspaces/{wsId}/documents — add a document after support files exist. */
|
|
57
|
+
export declare function postWorkspaceDocument(http: HttpClient, wsId: string, docPath: string, kind: DocumentKind, body: string): Promise<DocumentEntity>;
|
|
58
|
+
/** GET /v1/workspaces/{wsId}/documents/{docId} — read current document (JSON). */
|
|
59
|
+
export declare function getDocument(http: HttpClient, wsId: string, docId: string): Promise<DocumentEntity>;
|
|
60
|
+
/** PUT /v1/workspaces/{wsId}/documents/{docId} — replace the raw body. */
|
|
61
|
+
export declare function putDocument(http: HttpClient, wsId: string, docId: string, body: string, contentType: string): Promise<DocumentEntity>;
|
|
62
|
+
/** DELETE /v1/workspaces/{wsId}/documents/{docId} — hard delete (204). */
|
|
63
|
+
export declare function deleteDocument(http: HttpClient, wsId: string, docId: string): Promise<void>;
|
|
64
|
+
export interface MeEntity {
|
|
65
|
+
user_id: string;
|
|
66
|
+
email: string | null;
|
|
67
|
+
active_org_id: string | null;
|
|
68
|
+
}
|
|
69
|
+
/** GET /v1/me — verify a stored key and return the identity behind it. */
|
|
70
|
+
export declare function getMe(http: HttpClient): Promise<MeEntity>;
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// The injectable HTTP layer. Everything that touches the network goes through an
|
|
2
|
+
// `HttpClient`, so commands can be tested by passing a stub — no live server.
|
|
3
|
+
const USER_AGENT = "tot-cli";
|
|
4
|
+
/**
|
|
5
|
+
* Wraps the global `fetch` into an `HttpClient`. Adds the bearer key (when set)
|
|
6
|
+
* and a User-Agent, joins the path onto the configured endpoint, and normalizes
|
|
7
|
+
* every response into `{status, json, text}` so callers never re-parse.
|
|
8
|
+
*/
|
|
9
|
+
export function createHttpClient(cfg, fetchImpl = fetch) {
|
|
10
|
+
const base = cfg.endpoint.replace(/\/+$/, "");
|
|
11
|
+
return {
|
|
12
|
+
async fetch(method, path, body, headers) {
|
|
13
|
+
const finalHeaders = {
|
|
14
|
+
"user-agent": USER_AGENT,
|
|
15
|
+
...headers,
|
|
16
|
+
};
|
|
17
|
+
if (cfg.key) {
|
|
18
|
+
finalHeaders["authorization"] = "Bearer " + cfg.key;
|
|
19
|
+
}
|
|
20
|
+
let res;
|
|
21
|
+
try {
|
|
22
|
+
res = await fetchImpl(base + path, {
|
|
23
|
+
method,
|
|
24
|
+
headers: finalHeaders,
|
|
25
|
+
body,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
catch (cause) {
|
|
29
|
+
// A DNS/offline/timeout error from fetch is an opaque `TypeError: fetch
|
|
30
|
+
// failed`. Rethrow with the endpoint so the user can tell a connectivity
|
|
31
|
+
// problem (or a wrong --endpoint) from a server error.
|
|
32
|
+
const reason = cause instanceof Error ? cause.message : String(cause);
|
|
33
|
+
throw new Error(`cannot reach ${base} (${reason}) — check your connection or --endpoint`, { cause });
|
|
34
|
+
}
|
|
35
|
+
const text = await res.text();
|
|
36
|
+
let json = null;
|
|
37
|
+
if (text.length > 0) {
|
|
38
|
+
try {
|
|
39
|
+
json = JSON.parse(text);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
json = null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return { status: res.status, json, text };
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/** Pulls the clearest error message out of a non-2xx response. */
|
|
50
|
+
export function errorMessage(res) {
|
|
51
|
+
if (res.json && typeof res.json === "object") {
|
|
52
|
+
// The API's error envelope is `{error: {code, message}}` or `{message}`.
|
|
53
|
+
const e = res.json.error ?? res.json;
|
|
54
|
+
if (e && typeof e === "object") {
|
|
55
|
+
if (typeof e.message === "string")
|
|
56
|
+
return e.message;
|
|
57
|
+
if (typeof e.code === "string")
|
|
58
|
+
return e.code;
|
|
59
|
+
}
|
|
60
|
+
if (typeof e === "string")
|
|
61
|
+
return e;
|
|
62
|
+
}
|
|
63
|
+
if (res.text)
|
|
64
|
+
return res.text.slice(0, 500);
|
|
65
|
+
return `HTTP ${res.status}`;
|
|
66
|
+
}
|
|
67
|
+
/** POST /v1/documents — create a new anonymous (open) document. */
|
|
68
|
+
export async function postDocument(http, kind, body) {
|
|
69
|
+
const res = await http.fetch("POST", "/v1/documents", JSON.stringify({ kind, body }), {
|
|
70
|
+
"content-type": "application/json",
|
|
71
|
+
});
|
|
72
|
+
if (res.status !== 201 && res.status !== 200) {
|
|
73
|
+
throw new Error(errorMessage(res));
|
|
74
|
+
}
|
|
75
|
+
if (!res.json || !res.json.document || !res.json.workspace) {
|
|
76
|
+
throw new Error("unexpected create response: missing document/workspace");
|
|
77
|
+
}
|
|
78
|
+
return res.json;
|
|
79
|
+
}
|
|
80
|
+
/** POST /v1/workspaces — create an empty workspace shell. */
|
|
81
|
+
export async function postWorkspace(http) {
|
|
82
|
+
const res = await http.fetch("POST", "/v1/workspaces", JSON.stringify({}), {
|
|
83
|
+
"content-type": "application/json",
|
|
84
|
+
});
|
|
85
|
+
if (res.status !== 201 && res.status !== 200) {
|
|
86
|
+
throw new Error(errorMessage(res));
|
|
87
|
+
}
|
|
88
|
+
if (!res.json || !res.json.workspace) {
|
|
89
|
+
throw new Error("unexpected create workspace response: missing workspace");
|
|
90
|
+
}
|
|
91
|
+
return res.json.workspace;
|
|
92
|
+
}
|
|
93
|
+
/** PUT /v1/workspaces/{wsId}/assets/{assetPath} — upload/replace raw support bytes. */
|
|
94
|
+
export async function putAsset(http, wsId, assetPath, body, contentType) {
|
|
95
|
+
const res = await http.fetch("PUT", `/v1/workspaces/${encodeURIComponent(wsId)}/assets/${encodeAssetPath(assetPath)}`, body, { "content-type": contentType });
|
|
96
|
+
if (res.status !== 200) {
|
|
97
|
+
throw new Error(errorMessage(res));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** POST /v1/workspaces/{wsId}/documents — add a document after support files exist. */
|
|
101
|
+
export async function postWorkspaceDocument(http, wsId, docPath, kind, body) {
|
|
102
|
+
const res = await http.fetch("POST", `/v1/workspaces/${encodeURIComponent(wsId)}/documents`, JSON.stringify({ doc_path: docPath, kind, body }), { "content-type": "application/json" });
|
|
103
|
+
if (res.status !== 201 && res.status !== 200) {
|
|
104
|
+
throw new Error(errorMessage(res));
|
|
105
|
+
}
|
|
106
|
+
if (!res.json || !res.json.id) {
|
|
107
|
+
throw new Error("unexpected add document response: missing document");
|
|
108
|
+
}
|
|
109
|
+
return res.json;
|
|
110
|
+
}
|
|
111
|
+
/** GET /v1/workspaces/{wsId}/documents/{docId} — read current document (JSON). */
|
|
112
|
+
export async function getDocument(http, wsId, docId) {
|
|
113
|
+
const res = await http.fetch("GET", `/v1/workspaces/${encodeURIComponent(wsId)}/documents/${encodeURIComponent(docId)}`, undefined, { accept: "application/json" });
|
|
114
|
+
if (res.status !== 200) {
|
|
115
|
+
throw new Error(errorMessage(res));
|
|
116
|
+
}
|
|
117
|
+
return res.json;
|
|
118
|
+
}
|
|
119
|
+
/** PUT /v1/workspaces/{wsId}/documents/{docId} — replace the raw body. */
|
|
120
|
+
export async function putDocument(http, wsId, docId, body, contentType) {
|
|
121
|
+
const res = await http.fetch("PUT", `/v1/workspaces/${encodeURIComponent(wsId)}/documents/${encodeURIComponent(docId)}`, body, { "content-type": contentType });
|
|
122
|
+
if (res.status !== 200) {
|
|
123
|
+
throw new Error(errorMessage(res));
|
|
124
|
+
}
|
|
125
|
+
return res.json;
|
|
126
|
+
}
|
|
127
|
+
/** DELETE /v1/workspaces/{wsId}/documents/{docId} — hard delete (204). */
|
|
128
|
+
export async function deleteDocument(http, wsId, docId) {
|
|
129
|
+
const res = await http.fetch("DELETE", `/v1/workspaces/${encodeURIComponent(wsId)}/documents/${encodeURIComponent(docId)}`);
|
|
130
|
+
// 204 = deleted; 404 = already gone — treat both as success for idempotency.
|
|
131
|
+
if (res.status !== 204 && res.status !== 404) {
|
|
132
|
+
throw new Error(errorMessage(res));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** GET /v1/me — verify a stored key and return the identity behind it. */
|
|
136
|
+
export async function getMe(http) {
|
|
137
|
+
const res = await http.fetch("GET", "/v1/me", undefined, { accept: "application/json" });
|
|
138
|
+
if (res.status !== 200) {
|
|
139
|
+
throw new Error(errorMessage(res));
|
|
140
|
+
}
|
|
141
|
+
return res.json;
|
|
142
|
+
}
|
|
143
|
+
function encodeAssetPath(assetPath) {
|
|
144
|
+
return assetPath.split("/").map(encodeURIComponent).join("/");
|
|
145
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Library surface (the CLI lives in cli.ts). Exposed mostly so the published
|
|
2
|
+
// types resolve and the HTTP/command layer can be reused programmatically.
|
|
3
|
+
export * from "./commands.js";
|
|
4
|
+
export * from "./config.js";
|
|
5
|
+
export * from "./http.js";
|
|
6
|
+
export { main } from "./cli.js";
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@plannotator/tot",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Publish a markdown or HTML file to a living tot.page URL. A tiny CLI over the Workspaces /v1 API.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"cli",
|
|
7
|
+
"markdown",
|
|
8
|
+
"plannotator",
|
|
9
|
+
"publish",
|
|
10
|
+
"tot",
|
|
11
|
+
"tot.page"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/plannotator/tot#readme",
|
|
14
|
+
"bugs": "https://github.com/plannotator/tot/issues",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/plannotator/tot.git"
|
|
19
|
+
},
|
|
20
|
+
"bin": {
|
|
21
|
+
"tot": "dist/cli.js"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist"
|
|
25
|
+
],
|
|
26
|
+
"type": "module",
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"import": "./dist/index.js"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsc -p tsconfig.build.json",
|
|
38
|
+
"lint": "oxlint",
|
|
39
|
+
"lint:fix": "oxlint --fix",
|
|
40
|
+
"format": "oxfmt --write .",
|
|
41
|
+
"format:check": "oxfmt --check .",
|
|
42
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
43
|
+
"test": "vitest run",
|
|
44
|
+
"prepublishOnly": "pnpm lint && pnpm typecheck && pnpm test && pnpm build"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"parse5": "^8.0.1"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/node": "^22.19.21",
|
|
51
|
+
"oxfmt": "^0.43.0",
|
|
52
|
+
"oxlint": "^1.58.0",
|
|
53
|
+
"typescript": "6.0.2",
|
|
54
|
+
"vitest": "^3.0.0"
|
|
55
|
+
},
|
|
56
|
+
"engines": {
|
|
57
|
+
"node": ">=20.19"
|
|
58
|
+
},
|
|
59
|
+
"packageManager": "pnpm@10.18.0"
|
|
60
|
+
}
|