@openparachute/hub 0.5.2 → 0.5.9-rc.6
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/package.json +1 -1
- package/src/__tests__/admin-clients.test.ts +275 -0
- package/src/__tests__/admin-handlers.test.ts +159 -320
- package/src/__tests__/admin-host-admin-token.test.ts +52 -4
- package/src/__tests__/api-me.test.ts +149 -0
- package/src/__tests__/api-mint-token.test.ts +381 -0
- package/src/__tests__/api-revocation-list.test.ts +198 -0
- package/src/__tests__/api-revoke-token.test.ts +320 -0
- package/src/__tests__/api-tokens.test.ts +629 -0
- package/src/__tests__/auth.test.ts +680 -16
- package/src/__tests__/expose-2fa-warning.test.ts +123 -0
- package/src/__tests__/expose-cloudflare.test.ts +101 -0
- package/src/__tests__/expose.test.ts +199 -340
- package/src/__tests__/hub-server.test.ts +986 -66
- package/src/__tests__/hub.test.ts +108 -55
- package/src/__tests__/install-source.test.ts +249 -0
- package/src/__tests__/install.test.ts +50 -31
- package/src/__tests__/jwt-sign.test.ts +205 -0
- package/src/__tests__/lifecycle.test.ts +97 -2
- package/src/__tests__/module-manifest.test.ts +48 -0
- package/src/__tests__/notes-serve.test.ts +154 -2
- package/src/__tests__/oauth-handlers.test.ts +1000 -3
- package/src/__tests__/operator-token.test.ts +379 -3
- package/src/__tests__/origin-check.test.ts +220 -0
- package/src/__tests__/port-assign.test.ts +41 -52
- package/src/__tests__/rate-limit.test.ts +190 -0
- package/src/__tests__/services-manifest.test.ts +341 -0
- package/src/__tests__/setup.test.ts +12 -9
- package/src/__tests__/status.test.ts +372 -0
- package/src/__tests__/well-known.test.ts +69 -0
- package/src/admin-clients.ts +139 -0
- package/src/admin-handlers.ts +63 -260
- package/src/admin-host-admin-token.ts +25 -10
- package/src/admin-login-ui.ts +256 -0
- package/src/admin-vault-admin-token.ts +1 -1
- package/src/api-me.ts +124 -0
- package/src/api-mint-token.ts +239 -0
- package/src/api-revocation-list.ts +59 -0
- package/src/api-revoke-token.ts +153 -0
- package/src/api-tokens.ts +224 -0
- package/src/commands/auth.ts +408 -51
- package/src/commands/expose-2fa-warning.ts +82 -0
- package/src/commands/expose-cloudflare.ts +27 -0
- package/src/commands/expose-public-auto.ts +3 -7
- package/src/commands/expose.ts +88 -173
- package/src/commands/install.ts +11 -13
- package/src/commands/lifecycle.ts +53 -4
- package/src/commands/status.ts +99 -8
- package/src/csrf.ts +6 -3
- package/src/help.ts +13 -7
- package/src/hub-db.ts +63 -0
- package/src/hub-server.ts +572 -106
- package/src/hub.ts +272 -149
- package/src/install-source.ts +291 -0
- package/src/jwt-sign.ts +265 -5
- package/src/module-manifest.ts +48 -10
- package/src/notes-serve.ts +70 -9
- package/src/oauth-handlers.ts +395 -29
- package/src/oauth-ui.ts +188 -0
- package/src/operator-token.ts +272 -18
- package/src/origin-check.ts +127 -0
- package/src/port-assign.ts +28 -35
- package/src/rate-limit.ts +166 -0
- package/src/scope-explanations.ts +33 -2
- package/src/service-spec.ts +58 -13
- package/src/services-manifest.ts +62 -3
- package/src/sessions.ts +19 -0
- package/src/well-known.ts +54 -1
- package/web/ui/dist/assets/index-Bv6Bq_wx.js +60 -0
- package/web/ui/dist/assets/index-D54otIhv.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/admin-config.test.ts +0 -281
- package/src/admin-config-ui.ts +0 -534
- package/src/admin-config.ts +0 -226
- package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
- package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
:root{--bg: #faf8f4;--bg-soft: #f3f0ea;--fg: #2c2a26;--fg-muted: #6b6860;--fg-dim: #9a9690;--accent: #4a7c59;--accent-soft: rgba(74, 124, 89, .08);--accent-hover: #3d6849;--border: #e4e0d8;--border-light: #ece9e2;--card-bg: #ffffff;--error: #a3392b;--error-soft: rgba(163, 57, 43, .08);--warn: #b08023;--warn-soft: rgba(176, 128, 35, .08);--success: #3d6849;--success-soft: rgba(61, 104, 73, .08);--font-serif: Georgia, "Times New Roman", serif;--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;--font-mono: ui-monospace, "SF Mono", Menlo, Monaco, "Cascadia Mono", monospace;font-family:var(--font-sans)}*{box-sizing:border-box}html,body{margin:0;padding:0;background:var(--bg);color:var(--fg)}a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}button{font:inherit;background:var(--accent);color:#fff;border:0;border-radius:6px;padding:.55rem 1.1rem;cursor:pointer;transition:background .15s ease}button:hover{background:var(--accent-hover)}button:disabled{opacity:.5;cursor:not-allowed}button.secondary{background:#fff;color:var(--fg);border:1px solid var(--border)}button.secondary:hover{background:var(--bg-soft)}input,select,textarea{font:inherit;background:#fff;border:1px solid var(--border);border-radius:6px;padding:.55rem .75rem;color:var(--fg)}input:focus,select:focus,textarea:focus{outline:none;border-color:var(--accent)}code{font-family:var(--font-mono);font-size:.85em;background:var(--bg-soft);padding:.1em .3em;border-radius:3px}.page{max-width:880px;margin:0 auto;padding:1.5rem 1.5rem 6rem}.nav{display:flex;gap:1rem;align-items:center;padding-bottom:1rem;border-bottom:1px solid var(--border);margin-bottom:2rem}.nav .brand{font-weight:600;font-family:var(--font-serif);font-size:1.15rem;margin-right:auto}.nav .brand .sub{color:var(--fg-dim);font-size:.78rem;font-weight:400;margin-left:.4rem;font-family:var(--font-sans)}.nav a{color:var(--fg-muted);font-size:.95rem}.nav a:hover{text-decoration:none;color:var(--fg)}.nav .nav-divider{display:inline-block;width:1px;height:1.1em;background:var(--border);align-self:center}.nav .auth-spa{font-size:.85rem;color:var(--fg-muted)}.nav .auth-spa strong{font-weight:600;color:var(--fg)}.nav .auth-spa-signout{background:none;border:none;padding:0;color:var(--accent);font:inherit;cursor:pointer;text-decoration:underline;text-decoration-thickness:1px;text-underline-offset:2px}.nav .auth-spa-signout:hover:not(:disabled){color:var(--accent-hover)}.nav .auth-spa-signout:disabled{color:var(--fg-dim);cursor:not-allowed}h2{margin:0 0 1rem;font-size:1.4rem;font-weight:500}.muted{color:var(--fg-muted);font-size:.92rem}.dim{color:var(--fg-dim);font-size:.85rem}.error-banner{background:var(--error-soft);border:1px solid var(--error);color:var(--error);padding:.75rem 1rem;border-radius:8px;margin-bottom:1rem;font-size:.9rem}.warn-banner{background:var(--warn-soft);border:1px solid var(--warn);color:var(--warn);padding:.75rem 1rem;border-radius:8px;margin-bottom:1rem;font-size:.9rem}.empty{padding:3rem 1.5rem;text-align:center;color:var(--fg-muted);background:var(--bg-soft);border-radius:10px}.empty-rich{text-align:left;padding:2rem 1.75rem;background:#fff;border:1px solid var(--border)}.empty-rich .empty-headline{font-size:1.05rem;color:var(--fg);margin:0 0 .5rem;font-weight:500}.list-header{display:flex;align-items:baseline;justify-content:space-between;gap:1rem;margin-bottom:1rem}.list-header h2{margin:0}.tag{display:inline-block;padding:.1em .55em;background:var(--accent-soft);color:var(--accent);border-radius:4px;font-size:.78rem;font-weight:500}.tag.muted{background:var(--bg-soft);color:var(--fg-muted)}.tag.source-oauth{background:#4a7cc61f;color:#3b6aa6}.tag.source-operator{background:#c6984a24;color:#8a5e1f}.tag.source-cli{background:#4a7c5924;color:#2f5a3f}.tag.source-unknown{background:var(--bg-soft);color:var(--fg-muted)}@media(prefers-color-scheme:dark){.tag.source-oauth{background:#7a9cdc24;color:#9bb6d8}.tag.source-operator{background:#dcb46e24;color:#d4b27a}.tag.source-cli{background:#7ab08a24;color:#8fc49e}.tag.source-unknown{background:#e8e4dc0f;color:#a8a49a}}.vault-row{display:flex;align-items:center;gap:1rem;padding:.85rem 1rem;background:#fff;border:1px solid var(--border);border-radius:8px;margin-bottom:.5rem;text-decoration:none;color:inherit;transition:border-color .15s ease}.vault-row:hover{border-color:var(--accent);text-decoration:none}.vault-row .body{flex:1;min-width:0}.vault-row .name{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap}.vault-row .name code{font-size:.95em}.vault-row .url{margin-top:.25rem;word-break:break-all}.vault-row .chev{color:var(--fg-dim);font-size:1.2rem}form .row{margin-bottom:1rem}form label{display:block;font-size:.9rem;color:var(--fg-muted);margin-bottom:.3rem;font-weight:500}form input[type=text]{width:100%}form .actions{display:flex;gap:.6rem;align-items:center;margin-top:1rem}form .field-hint{margin-top:.35rem;font-size:.82rem;color:var(--fg-dim)}form .field-error{margin-top:.35rem;font-size:.85rem;color:var(--error)}.section{background:#fff;border:1px solid var(--border);border-radius:10px;padding:1.25rem 1.5rem;margin-bottom:1.5rem}.mint-banner{background:var(--success-soft);border:1px solid var(--success);border-radius:10px;padding:1.25rem 1.5rem;margin-bottom:1.5rem}.mint-banner h3{margin:0 0 .5rem;font-size:1rem;color:var(--success)}.mint-banner .token-box{display:flex;align-items:center;gap:.5rem;margin:.85rem 0 .5rem}.mint-banner code{flex:1;font-size:.9rem;padding:.6rem .75rem;background:#fff;border:1px solid var(--border);word-break:break-all;-webkit-user-select:all;user-select:all}.mint-banner .warn{margin:.75rem 0 0;font-size:.85rem;color:var(--warn)}.mint-banner .actions{margin-top:1rem;display:flex;gap:.5rem}.kv{display:grid;grid-template-columns:8.5rem 1fr;gap:.5rem 1rem;font-size:.92rem}.kv>div:nth-child(odd){color:var(--fg-muted)}.kv code{word-break:break-all}
|
package/web/ui/dist/index.html
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>Parachute Hub</title>
|
|
7
7
|
<meta name="description" content="Manage vaults registered with this Parachute hub." />
|
|
8
|
-
<script type="module" crossorigin src="/
|
|
9
|
-
<link rel="stylesheet" crossorigin href="/
|
|
8
|
+
<script type="module" crossorigin src="/admin/assets/index-Bv6Bq_wx.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="/admin/assets/index-D54otIhv.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
|
12
12
|
<div id="root"></div>
|
|
@@ -1,281 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
import {
|
|
6
|
-
configPathFor,
|
|
7
|
-
discoverConfigurableModules,
|
|
8
|
-
readModuleConfig,
|
|
9
|
-
validateAndCoerce,
|
|
10
|
-
writeModuleConfig,
|
|
11
|
-
} from "../admin-config.ts";
|
|
12
|
-
import type { ConfigSchema, ModuleManifest } from "../module-manifest.ts";
|
|
13
|
-
import type { ServicesManifest } from "../services-manifest.ts";
|
|
14
|
-
|
|
15
|
-
function tmp(): string {
|
|
16
|
-
return mkdtempSync(join(tmpdir(), "admin-config-"));
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const VAULT_SCHEMA: ConfigSchema = {
|
|
20
|
-
type: "object",
|
|
21
|
-
required: ["transcribe_provider"],
|
|
22
|
-
properties: {
|
|
23
|
-
transcribe_provider: {
|
|
24
|
-
type: "string",
|
|
25
|
-
description: "Speech-to-text backend.",
|
|
26
|
-
enum: ["openai", "deepgram", "groq"],
|
|
27
|
-
default: "openai",
|
|
28
|
-
},
|
|
29
|
-
max_tags_per_note: { type: "integer", default: 10 },
|
|
30
|
-
public: { type: "boolean", default: false },
|
|
31
|
-
},
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
const VAULT_MANIFEST: ModuleManifest = {
|
|
35
|
-
name: "vault",
|
|
36
|
-
manifestName: "parachute-vault",
|
|
37
|
-
displayName: "Vault",
|
|
38
|
-
kind: "api",
|
|
39
|
-
port: 1940,
|
|
40
|
-
paths: ["/vault"],
|
|
41
|
-
health: "/health",
|
|
42
|
-
configSchema: VAULT_SCHEMA,
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
const NOTES_MANIFEST: ModuleManifest = {
|
|
46
|
-
name: "notes",
|
|
47
|
-
manifestName: "parachute-notes",
|
|
48
|
-
displayName: "Notes",
|
|
49
|
-
kind: "frontend",
|
|
50
|
-
port: 1941,
|
|
51
|
-
paths: ["/"],
|
|
52
|
-
health: "/health",
|
|
53
|
-
// No configSchema — should be skipped.
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
function services(...entries: { name: string; installDir?: string }[]): ServicesManifest {
|
|
57
|
-
return {
|
|
58
|
-
services: entries.map((e) => ({
|
|
59
|
-
name: e.name,
|
|
60
|
-
port: 1940,
|
|
61
|
-
paths: ["/"],
|
|
62
|
-
health: "/health",
|
|
63
|
-
version: "0.0.0",
|
|
64
|
-
...(e.installDir ? { installDir: e.installDir } : {}),
|
|
65
|
-
})),
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
describe("discoverConfigurableModules", () => {
|
|
70
|
-
test("includes only modules with a configSchema", async () => {
|
|
71
|
-
const dir = tmp();
|
|
72
|
-
try {
|
|
73
|
-
const result = await discoverConfigurableModules({
|
|
74
|
-
loadServicesManifest: () =>
|
|
75
|
-
services(
|
|
76
|
-
{ name: "vault", installDir: "/fake/vault" },
|
|
77
|
-
{ name: "notes", installDir: "/fake/notes" },
|
|
78
|
-
),
|
|
79
|
-
configDir: dir,
|
|
80
|
-
readManifest: async (installDir) => {
|
|
81
|
-
if (installDir === "/fake/vault") return VAULT_MANIFEST;
|
|
82
|
-
if (installDir === "/fake/notes") return NOTES_MANIFEST;
|
|
83
|
-
return null;
|
|
84
|
-
},
|
|
85
|
-
});
|
|
86
|
-
expect(result.map((m) => m.name)).toEqual(["vault"]);
|
|
87
|
-
const first = result[0]!;
|
|
88
|
-
expect(first.schema).toBe(VAULT_SCHEMA);
|
|
89
|
-
expect(first.configPath).toBe(configPathFor(dir, "vault"));
|
|
90
|
-
} finally {
|
|
91
|
-
rmSync(dir, { recursive: true, force: true });
|
|
92
|
-
}
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
test("skips entries without an installDir", async () => {
|
|
96
|
-
const result = await discoverConfigurableModules({
|
|
97
|
-
loadServicesManifest: () => services({ name: "vault" }),
|
|
98
|
-
configDir: tmp(),
|
|
99
|
-
readManifest: async () => VAULT_MANIFEST,
|
|
100
|
-
});
|
|
101
|
-
expect(result).toEqual([]);
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
test("skips entries whose manifest fails to read (returns null)", async () => {
|
|
105
|
-
const result = await discoverConfigurableModules({
|
|
106
|
-
loadServicesManifest: () => services({ name: "vault", installDir: "/missing" }),
|
|
107
|
-
configDir: tmp(),
|
|
108
|
-
readManifest: async () => null,
|
|
109
|
-
});
|
|
110
|
-
expect(result).toEqual([]);
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
test("doesn't take down the portal when one manifest throws", async () => {
|
|
114
|
-
const result = await discoverConfigurableModules({
|
|
115
|
-
loadServicesManifest: () =>
|
|
116
|
-
services(
|
|
117
|
-
{ name: "vault", installDir: "/fake/vault" },
|
|
118
|
-
{ name: "rogue", installDir: "/fake/rogue" },
|
|
119
|
-
),
|
|
120
|
-
configDir: tmp(),
|
|
121
|
-
readManifest: async (installDir) => {
|
|
122
|
-
if (installDir === "/fake/vault") return VAULT_MANIFEST;
|
|
123
|
-
throw new Error("malformed module.json");
|
|
124
|
-
},
|
|
125
|
-
});
|
|
126
|
-
expect(result.map((m) => m.name)).toEqual(["vault"]);
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
test("sorts results by displayName", async () => {
|
|
130
|
-
const aManifest: ModuleManifest = { ...VAULT_MANIFEST, name: "alpha", displayName: "Alpha" };
|
|
131
|
-
const zManifest: ModuleManifest = { ...VAULT_MANIFEST, name: "omega", displayName: "Omega" };
|
|
132
|
-
const result = await discoverConfigurableModules({
|
|
133
|
-
loadServicesManifest: () =>
|
|
134
|
-
services({ name: "omega", installDir: "/o" }, { name: "alpha", installDir: "/a" }),
|
|
135
|
-
configDir: tmp(),
|
|
136
|
-
readManifest: async (d) => (d === "/o" ? zManifest : aManifest),
|
|
137
|
-
});
|
|
138
|
-
expect(result.map((m) => m.name)).toEqual(["alpha", "omega"]);
|
|
139
|
-
});
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
describe("validateAndCoerce", () => {
|
|
143
|
-
test("coerces strings, integers, numbers, booleans", () => {
|
|
144
|
-
const r = validateAndCoerce(
|
|
145
|
-
{
|
|
146
|
-
transcribe_provider: "deepgram",
|
|
147
|
-
max_tags_per_note: "42",
|
|
148
|
-
public: true,
|
|
149
|
-
},
|
|
150
|
-
VAULT_SCHEMA,
|
|
151
|
-
);
|
|
152
|
-
expect(r.ok).toBe(true);
|
|
153
|
-
expect(r.data).toEqual({
|
|
154
|
-
transcribe_provider: "deepgram",
|
|
155
|
-
max_tags_per_note: 42,
|
|
156
|
-
public: true,
|
|
157
|
-
});
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
test("rejects an integer that is not an integer", () => {
|
|
161
|
-
const r = validateAndCoerce(
|
|
162
|
-
{ transcribe_provider: "openai", max_tags_per_note: "3.14", public: false },
|
|
163
|
-
VAULT_SCHEMA,
|
|
164
|
-
);
|
|
165
|
-
expect(r.ok).toBe(false);
|
|
166
|
-
expect(r.errors.max_tags_per_note).toBe("must be an integer");
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
test("rejects values outside the enum", () => {
|
|
170
|
-
const r = validateAndCoerce(
|
|
171
|
-
{ transcribe_provider: "whisper", max_tags_per_note: "10", public: false },
|
|
172
|
-
VAULT_SCHEMA,
|
|
173
|
-
);
|
|
174
|
-
expect(r.ok).toBe(false);
|
|
175
|
-
expect(r.errors.transcribe_provider).toContain("must be one of");
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
test("rejects required fields when missing", () => {
|
|
179
|
-
const r = validateAndCoerce({ public: false }, VAULT_SCHEMA);
|
|
180
|
-
expect(r.ok).toBe(false);
|
|
181
|
-
expect(r.errors.transcribe_provider).toBe("required");
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
test("missing optional non-boolean fields are omitted from output", () => {
|
|
185
|
-
const r = validateAndCoerce({ transcribe_provider: "openai", public: false }, VAULT_SCHEMA);
|
|
186
|
-
expect(r.ok).toBe(true);
|
|
187
|
-
expect(r.data).toEqual({ transcribe_provider: "openai", public: false });
|
|
188
|
-
expect("max_tags_per_note" in (r.data ?? {})).toBe(false);
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
test("missing booleans default to false rather than failing required", () => {
|
|
192
|
-
const required: ConfigSchema = {
|
|
193
|
-
type: "object",
|
|
194
|
-
required: ["public"],
|
|
195
|
-
properties: { public: { type: "boolean" } },
|
|
196
|
-
};
|
|
197
|
-
const r = validateAndCoerce({}, required);
|
|
198
|
-
expect(r.ok).toBe(true);
|
|
199
|
-
expect(r.data).toEqual({ public: false });
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
test("number coercion accepts decimals", () => {
|
|
203
|
-
const schema: ConfigSchema = {
|
|
204
|
-
type: "object",
|
|
205
|
-
properties: { ratio: { type: "number" } },
|
|
206
|
-
};
|
|
207
|
-
expect(validateAndCoerce({ ratio: "0.25" }, schema).data).toEqual({ ratio: 0.25 });
|
|
208
|
-
expect(validateAndCoerce({ ratio: "garbage" }, schema).errors.ratio).toBe("must be a number");
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
test("string values pass through verbatim", () => {
|
|
212
|
-
const schema: ConfigSchema = {
|
|
213
|
-
type: "object",
|
|
214
|
-
properties: { motto: { type: "string" } },
|
|
215
|
-
};
|
|
216
|
-
const r = validateAndCoerce({ motto: " whitespace preserved " }, schema);
|
|
217
|
-
expect(r.data?.motto).toBe(" whitespace preserved ");
|
|
218
|
-
});
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
describe("readModuleConfig + writeModuleConfig", () => {
|
|
222
|
-
test("returns {} when the file does not exist", () => {
|
|
223
|
-
const dir = tmp();
|
|
224
|
-
try {
|
|
225
|
-
expect(readModuleConfig(join(dir, "missing.json"))).toEqual({ data: {} });
|
|
226
|
-
} finally {
|
|
227
|
-
rmSync(dir, { recursive: true, force: true });
|
|
228
|
-
}
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
test("round-trips a config object atomically", () => {
|
|
232
|
-
const dir = tmp();
|
|
233
|
-
try {
|
|
234
|
-
const path = join(dir, "vault", "config.json");
|
|
235
|
-
writeModuleConfig(path, { transcribe_provider: "openai", max_tags_per_note: 10 });
|
|
236
|
-
expect(existsSync(path)).toBe(true);
|
|
237
|
-
const { data, parseError } = readModuleConfig(path);
|
|
238
|
-
expect(parseError).toBeUndefined();
|
|
239
|
-
expect(data).toEqual({ transcribe_provider: "openai", max_tags_per_note: 10 });
|
|
240
|
-
} finally {
|
|
241
|
-
rmSync(dir, { recursive: true, force: true });
|
|
242
|
-
}
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
test("surfaces a parse error without erroring", () => {
|
|
246
|
-
const dir = tmp();
|
|
247
|
-
try {
|
|
248
|
-
const path = join(dir, "config.json");
|
|
249
|
-
writeFileSync(path, "{not valid json");
|
|
250
|
-
const r = readModuleConfig(path);
|
|
251
|
-
expect(r.data).toEqual({});
|
|
252
|
-
expect(r.parseError).toBeDefined();
|
|
253
|
-
} finally {
|
|
254
|
-
rmSync(dir, { recursive: true, force: true });
|
|
255
|
-
}
|
|
256
|
-
});
|
|
257
|
-
|
|
258
|
-
test("surfaces a parse error when the file is a JSON array, not object", () => {
|
|
259
|
-
const dir = tmp();
|
|
260
|
-
try {
|
|
261
|
-
const path = join(dir, "config.json");
|
|
262
|
-
writeFileSync(path, "[]");
|
|
263
|
-
const r = readModuleConfig(path);
|
|
264
|
-
expect(r.data).toEqual({});
|
|
265
|
-
expect(r.parseError).toContain("must contain a JSON object");
|
|
266
|
-
} finally {
|
|
267
|
-
rmSync(dir, { recursive: true, force: true });
|
|
268
|
-
}
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
test("trailing newline preserved on write", () => {
|
|
272
|
-
const dir = tmp();
|
|
273
|
-
try {
|
|
274
|
-
const path = join(dir, "config.json");
|
|
275
|
-
writeModuleConfig(path, { x: 1 });
|
|
276
|
-
expect(readFileSync(path, "utf8").endsWith("\n")).toBe(true);
|
|
277
|
-
} finally {
|
|
278
|
-
rmSync(dir, { recursive: true, force: true });
|
|
279
|
-
}
|
|
280
|
-
});
|
|
281
|
-
});
|