artifacty 0.7.0 → 0.9.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.
@@ -0,0 +1,214 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { arch, platform } from "node:os";
4
+ import { backgroundStatus } from "./background.js";
5
+ import { checkMcpTools } from "./check.js";
6
+ import { securityConfig, validateServerExposure, exposureWarning } from "./security.js";
7
+ import { serviceCommand } from "./service.js";
8
+ import { checkStoreIntegrity, createStore } from "./storage.js";
9
+
10
+ const MIN_NODE_VERSION = "22.5.0";
11
+
12
+ export async function runDoctor(options = {}) {
13
+ const packageRoot = path.resolve(options.packageRoot || process.cwd());
14
+ const store = createStore({ home: options.home });
15
+ const checks = [];
16
+
17
+ await collect(checks, "runtime", () => runtimeCheck(packageRoot));
18
+ await collect(checks, "security", () => securityCheck(options));
19
+ await collect(checks, "storage", () => storageCheck(store));
20
+ await collect(checks, "server", () => serverCheck(store));
21
+ await collect(checks, "service", () => serviceCheck(options));
22
+ if (options.skipMcp) {
23
+ checks.push({
24
+ name: "mcp",
25
+ status: "skip",
26
+ message: "MCP discovery skipped by --skip-mcp"
27
+ });
28
+ } else {
29
+ await collect(checks, "mcp", () => mcpCheck(packageRoot, options));
30
+ }
31
+
32
+ const failures = checks.filter((check) => check.status === "fail");
33
+ const warnings = checks.filter((check) => check.status === "warn");
34
+
35
+ return {
36
+ ok: failures.length === 0,
37
+ checkedAt: new Date().toISOString(),
38
+ version: await packageVersion(packageRoot),
39
+ home: store.home,
40
+ platform: process.platform,
41
+ checks,
42
+ failures: failures.map(({ name, message }) => ({ name, message })),
43
+ warnings: warnings.map(({ name, message }) => ({ name, message }))
44
+ };
45
+ }
46
+
47
+ async function collect(checks, name, fn) {
48
+ try {
49
+ checks.push({ name, ...(await fn()) });
50
+ } catch (error) {
51
+ checks.push({
52
+ name,
53
+ status: "fail",
54
+ message: error.message,
55
+ error: {
56
+ name: error.name,
57
+ code: error.code
58
+ }
59
+ });
60
+ }
61
+ }
62
+
63
+ async function runtimeCheck(packageRoot) {
64
+ const version = await packageVersion(packageRoot);
65
+ const supported = compareVersions(process.versions.node, MIN_NODE_VERSION) >= 0;
66
+ return {
67
+ status: supported ? "pass" : "fail",
68
+ message: supported
69
+ ? `Node ${process.versions.node} satisfies >=${MIN_NODE_VERSION}`
70
+ : `Node ${process.versions.node} is below required >=${MIN_NODE_VERSION}`,
71
+ data: {
72
+ artifactyVersion: version,
73
+ nodeVersion: process.versions.node,
74
+ requiredNodeVersion: `>=${MIN_NODE_VERSION}`,
75
+ platform: platform(),
76
+ arch: arch()
77
+ }
78
+ };
79
+ }
80
+
81
+ function securityCheck(options = {}) {
82
+ const host = options.host || process.env.ARTIFACTY_HOST || "127.0.0.1";
83
+ const config = securityConfig(options);
84
+ validateServerExposure({ host, config });
85
+ const warning = exposureWarning({ host, config });
86
+ if (warning) {
87
+ return {
88
+ status: "warn",
89
+ message: warning,
90
+ data: {
91
+ host,
92
+ shareMode: config.shareMode,
93
+ hasApiToken: Boolean(config.apiToken)
94
+ }
95
+ };
96
+ }
97
+ return {
98
+ status: "pass",
99
+ message: "Server exposure settings are local-first",
100
+ data: {
101
+ host,
102
+ shareMode: config.shareMode,
103
+ hasApiToken: Boolean(config.apiToken)
104
+ }
105
+ };
106
+ }
107
+
108
+ async function storageCheck(store) {
109
+ const integrity = await checkStoreIntegrity(store);
110
+ return {
111
+ status: integrity.ok ? "pass" : "fail",
112
+ message: integrity.ok
113
+ ? `Store is consistent with ${integrity.artifactCount} artifacts and ${integrity.versionCount} versions`
114
+ : "Store integrity check found missing, changed, orphaned, or inconsistent files",
115
+ data: integrity
116
+ };
117
+ }
118
+
119
+ async function serverCheck(store) {
120
+ const status = await backgroundStatus({ home: store.home });
121
+ if (status.running) {
122
+ return {
123
+ status: "pass",
124
+ message: `Managed server is healthy at ${status.url}`,
125
+ data: status
126
+ };
127
+ }
128
+ if (status.processRunning || status.pidFileExists) {
129
+ return {
130
+ status: "fail",
131
+ message: "Recorded server process or pid file exists but health check is not passing",
132
+ data: status
133
+ };
134
+ }
135
+ return {
136
+ status: "warn",
137
+ message: "No managed Artifacty server is currently running",
138
+ data: status
139
+ };
140
+ }
141
+
142
+ async function serviceCheck(options = {}) {
143
+ const definitions = [];
144
+ for (const action of ["plist", "unit", "task"]) {
145
+ const result = await serviceCommand(action, {
146
+ projectDir: options.packageRoot || process.cwd(),
147
+ serverPath: options.serverPath,
148
+ host: options.host,
149
+ port: options.port,
150
+ home: options.home,
151
+ apiToken: options.apiToken,
152
+ shareMode: options.shareMode,
153
+ allowSecrets: options.allowSecrets,
154
+ dryRun: true
155
+ });
156
+ definitions.push({
157
+ action,
158
+ platform: result.platform,
159
+ path: result.path,
160
+ contentBytes: Buffer.byteLength(result.content || "", "utf8")
161
+ });
162
+ }
163
+ return {
164
+ status: "pass",
165
+ message: "Service definitions render for macOS, Linux, and Windows",
166
+ data: { definitions }
167
+ };
168
+ }
169
+
170
+ async function mcpCheck(packageRoot, options = {}) {
171
+ const result = await checkMcpTools({
172
+ projectDir: packageRoot,
173
+ serverPath: options.serverPath,
174
+ url: options.url,
175
+ home: options.home,
176
+ timeout: options.timeout
177
+ });
178
+ return {
179
+ status: result.ok ? "pass" : "fail",
180
+ message: result.ok
181
+ ? `MCP discovery found ${result.toolCount} tools, ${result.resourceCount} resources, and ${result.promptCount} prompts`
182
+ : "MCP discovery is missing required tools, resources, or prompts",
183
+ data: result
184
+ };
185
+ }
186
+
187
+ async function packageVersion(packageRoot) {
188
+ const file = path.join(packageRoot, "package.json");
189
+ const parsed = JSON.parse(await readFile(file, "utf8"));
190
+ return parsed.version;
191
+ }
192
+
193
+ function compareVersions(left, right) {
194
+ const leftParts = versionParts(left);
195
+ const rightParts = versionParts(right);
196
+ for (let index = 0; index < Math.max(leftParts.length, rightParts.length); index += 1) {
197
+ const leftPart = leftParts[index] || 0;
198
+ const rightPart = rightParts[index] || 0;
199
+ if (leftPart > rightPart) {
200
+ return 1;
201
+ }
202
+ if (leftPart < rightPart) {
203
+ return -1;
204
+ }
205
+ }
206
+ return 0;
207
+ }
208
+
209
+ function versionParts(value) {
210
+ return String(value)
211
+ .split(".")
212
+ .map((part) => Number.parseInt(part, 10))
213
+ .filter((part) => Number.isFinite(part));
214
+ }
@@ -48,6 +48,17 @@ export function createMcpServerConfig(options = {}) {
48
48
  env.ARTIFACTY_URL = options.url || process.env.ARTIFACTY_URL;
49
49
  }
50
50
 
51
+ if (options.mcpUrl || process.env.ARTIFACTY_MCP_URL) {
52
+ env.ARTIFACTY_MCP_MODE = normalizeMcpMode(options.transport || "bridge");
53
+ env.ARTIFACTY_MCP_URL = normalizeMcpEndpoint(options.mcpUrl || process.env.ARTIFACTY_MCP_URL);
54
+ } else if (options.transport || process.env.ARTIFACTY_MCP_MODE) {
55
+ env.ARTIFACTY_MCP_MODE = normalizeMcpMode(options.transport || process.env.ARTIFACTY_MCP_MODE);
56
+ }
57
+
58
+ if (options.apiToken || process.env.ARTIFACTY_API_TOKEN) {
59
+ env.ARTIFACTY_API_TOKEN = options.apiToken || process.env.ARTIFACTY_API_TOKEN;
60
+ }
61
+
51
62
  if (options.home || process.env.ARTIFACTY_HOME) {
52
63
  env.ARTIFACTY_HOME = path.resolve(options.home || process.env.ARTIFACTY_HOME);
53
64
  }
@@ -241,6 +252,26 @@ function normalizeTimeoutMs(value) {
241
252
  return Number.isFinite(timeout) && timeout > 0 ? timeout : DEFAULT_MCP_TIMEOUT_MS;
242
253
  }
243
254
 
255
+ function normalizeMcpMode(value) {
256
+ const mode = String(value || "bridge").trim().toLowerCase();
257
+ if (mode === "remote") {
258
+ return "bridge";
259
+ }
260
+ if (!["local", "bridge"].includes(mode)) {
261
+ throw new Error("MCP transport must be local or bridge");
262
+ }
263
+ return mode;
264
+ }
265
+
266
+ function normalizeMcpEndpoint(value) {
267
+ const parsed = new URL(value);
268
+ const pathname = parsed.pathname.replace(/\/+$/, "");
269
+ parsed.pathname = pathname || "/mcp";
270
+ parsed.search = "";
271
+ parsed.hash = "";
272
+ return parsed.toString();
273
+ }
274
+
244
275
  function quoteTomlString(value) {
245
276
  return JSON.stringify(String(value));
246
277
  }
package/src/lib/render.js CHANGED
@@ -2,7 +2,7 @@ import { EDITOR_CLIENT_PATH, VIEWER_CLIENT_PATH, editorImportMapJson } from "./e
2
2
  import { createI18n, DEFAULT_LOCALE, editorMessages, localizedHref, switchLocaleHref } from "./i18n.js";
3
3
  import { ARTIFACT_FORMATS, ARTIFACT_TYPES } from "./storage.js";
4
4
 
5
- export function renderDashboard({ artifacts, baseUrl, filters = {}, pagination, locale = DEFAULT_LOCALE, currentPath = "/" }) {
5
+ export function renderDashboard({ artifacts, baseUrl, filters = {}, pagination, locale = DEFAULT_LOCALE, currentPath = "/", user = null }) {
6
6
  const view = viewContext(locale, currentPath);
7
7
  const total = pagination?.total ?? artifacts.length;
8
8
  const start = artifacts.length ? (pagination?.offset ?? 0) + 1 : 0;
@@ -48,6 +48,7 @@ export function renderDashboard({ artifacts, baseUrl, filters = {}, pagination,
48
48
  <a href="${view.href("/new")}">${view.text("nav.new")}</a>
49
49
  <a href="${view.href("/import")}">${view.text("nav.import")}</a>
50
50
  <a href="/api/artifacts">${view.text("nav.api")}</a>
51
+ ${authNav(user)}
51
52
  ${languageSwitcher(view)}
52
53
  </nav>
53
54
  </header>
@@ -249,6 +250,170 @@ export function renderImportArtifactPage({ baseUrl, authToken = "", locale = DEF
249
250
  });
250
251
  }
251
252
 
253
+ export function renderLoginPage({ baseUrl, setup = false, error = "", locale = DEFAULT_LOCALE, currentPath = "/login" }) {
254
+ const view = viewContext(locale, currentPath);
255
+ const title = setup ? "Create admin account" : "Sign in";
256
+ return pageShell({
257
+ title,
258
+ body: `
259
+ <header class="topbar">
260
+ <div>
261
+ <h1>${escapeHtml(title)}</h1>
262
+ <p>${escapeHtml(baseUrl)}</p>
263
+ </div>
264
+ <nav>
265
+ <a href="${view.href("/")}">${view.text("nav.index")}</a>
266
+ ${languageSwitcher(view)}
267
+ </nav>
268
+ </header>
269
+ <main class="artifact-editor auth-panel">
270
+ ${error ? `<p class="auth-error">${escapeHtml(error)}</p>` : ""}
271
+ ${setup ? `<p class="muted">No users exist yet. The first account becomes an administrator.</p>` : ""}
272
+ <form class="editor-form auth-form" method="post" action="/login">
273
+ <section class="editor-fields">
274
+ <label class="field">
275
+ <span>Email</span>
276
+ <input type="email" name="email" autocomplete="username" required>
277
+ </label>
278
+ ${setup ? `<label class="field">
279
+ <span>Name</span>
280
+ <input name="name" autocomplete="name">
281
+ </label>` : ""}
282
+ <label class="field">
283
+ <span>Password</span>
284
+ <input type="password" name="password" autocomplete="${setup ? "new-password" : "current-password"}" minlength="8" required>
285
+ </label>
286
+ </section>
287
+ <footer class="editor-actions">
288
+ <button type="submit">${setup ? "Create admin" : "Sign in"}</button>
289
+ </footer>
290
+ </form>
291
+ </main>
292
+ `,
293
+ locale: view.locale
294
+ });
295
+ }
296
+
297
+ export function renderAccountPage({ baseUrl, user, tokens = [], createdToken = "", locale = DEFAULT_LOCALE, currentPath = "/account" }) {
298
+ const view = viewContext(locale, currentPath);
299
+ const rows = tokens.map((token) => `
300
+ <tr>
301
+ <td>${escapeHtml(token.name)}</td>
302
+ <td>${escapeHtml(token.createdAt)}</td>
303
+ <td>${token.lastUsedAt ? escapeHtml(token.lastUsedAt) : "Never"}</td>
304
+ <td>${token.revokedAt ? escapeHtml(token.revokedAt) : "Active"}</td>
305
+ <td>
306
+ ${token.revokedAt ? "" : `<form method="post" action="/account/tokens/${encodeURIComponent(token.id)}/revoke">
307
+ <button type="submit">Revoke</button>
308
+ </form>`}
309
+ </td>
310
+ </tr>
311
+ `).join("");
312
+
313
+ return pageShell({
314
+ title: "Account",
315
+ body: `
316
+ <header class="topbar">
317
+ <div>
318
+ <h1>Account</h1>
319
+ <p>${escapeHtml(user.email)} · ${escapeHtml(user.role)} · ${escapeHtml(baseUrl)}</p>
320
+ </div>
321
+ <nav>
322
+ <a href="${view.href("/")}">${view.text("nav.index")}</a>
323
+ ${user.role === "admin" ? `<a href="/admin/users">Users</a>` : ""}
324
+ <form class="nav-form" method="post" action="/logout"><button type="submit">Sign out</button></form>
325
+ ${languageSwitcher(view)}
326
+ </nav>
327
+ </header>
328
+ <main class="artifact-view">
329
+ ${createdToken ? `<section class="token-once">
330
+ <h2>New API token</h2>
331
+ <p>Copy this token now. Artifacty stores only its hash and cannot show it again.</p>
332
+ <pre class="artifact-code"><code>${escapeHtml(createdToken)}</code></pre>
333
+ </section>` : ""}
334
+ <section class="meta-card">
335
+ <h2>Profile</h2>
336
+ <p><strong>${escapeHtml(user.name)}</strong></p>
337
+ <p>${escapeHtml(user.email)}</p>
338
+ <p>Role: ${escapeHtml(user.role)}</p>
339
+ </section>
340
+ <section class="meta-card">
341
+ <h2>Create API token</h2>
342
+ <form class="inline-action" method="post" action="/account/tokens">
343
+ <input name="name" placeholder="Token name" autocomplete="off" required>
344
+ <button type="submit">Create token</button>
345
+ </form>
346
+ </section>
347
+ <section class="meta-card">
348
+ <h2>API tokens</h2>
349
+ <table class="data-table">
350
+ <thead><tr><th>Name</th><th>Created</th><th>Last used</th><th>Status</th><th></th></tr></thead>
351
+ <tbody>${rows || `<tr><td colspan="5">No API tokens.</td></tr>`}</tbody>
352
+ </table>
353
+ </section>
354
+ </main>
355
+ `,
356
+ locale: view.locale
357
+ });
358
+ }
359
+
360
+ export function renderAdminUsersPage({ baseUrl, user, users = [], locale = DEFAULT_LOCALE, currentPath = "/admin/users" }) {
361
+ const view = viewContext(locale, currentPath);
362
+ const rows = users.map((item) => `
363
+ <tr>
364
+ <td>${escapeHtml(item.email)}</td>
365
+ <td>${escapeHtml(item.name)}</td>
366
+ <td>${escapeHtml(item.role)}</td>
367
+ <td>${item.active ? "Active" : "Disabled"}</td>
368
+ <td>${escapeHtml(item.createdAt)}</td>
369
+ <td>
370
+ ${item.id === user.id ? "" : `<form method="post" action="/admin/users/${encodeURIComponent(item.id)}/${item.active ? "disable" : "enable"}">
371
+ <button type="submit">${item.active ? "Disable" : "Enable"}</button>
372
+ </form>`}
373
+ </td>
374
+ </tr>
375
+ `).join("");
376
+
377
+ return pageShell({
378
+ title: "Users",
379
+ body: `
380
+ <header class="topbar">
381
+ <div>
382
+ <h1>Users</h1>
383
+ <p>${escapeHtml(baseUrl)}</p>
384
+ </div>
385
+ <nav>
386
+ <a href="${view.href("/")}">${view.text("nav.index")}</a>
387
+ <a href="/account">Account</a>
388
+ ${languageSwitcher(view)}
389
+ </nav>
390
+ </header>
391
+ <main class="artifact-view">
392
+ <section class="meta-card">
393
+ <h2>Create user</h2>
394
+ <form class="editor-form auth-form" method="post" action="/admin/users">
395
+ <section class="editor-fields">
396
+ <label class="field"><span>Email</span><input type="email" name="email" required></label>
397
+ <label class="field"><span>Name</span><input name="name"></label>
398
+ <label class="field"><span>Role</span><select name="role"><option value="user">user</option><option value="admin">admin</option></select></label>
399
+ <label class="field"><span>Password</span><input type="password" name="password" minlength="8" required></label>
400
+ </section>
401
+ <footer class="editor-actions"><button type="submit">Create user</button></footer>
402
+ </form>
403
+ </section>
404
+ <section class="meta-card">
405
+ <h2>Existing users</h2>
406
+ <table class="data-table">
407
+ <thead><tr><th>Email</th><th>Name</th><th>Role</th><th>Status</th><th>Created</th><th></th></tr></thead>
408
+ <tbody>${rows}</tbody>
409
+ </table>
410
+ </section>
411
+ </main>
412
+ `,
413
+ locale: view.locale
414
+ });
415
+ }
416
+
252
417
  export function renderArtifactPage({ artifact, version, content, baseUrl, authToken = "", locale = DEFAULT_LOCALE, currentPath = "/" }) {
253
418
  const view = viewContext(locale, currentPath);
254
419
  const versionLinks = artifact.versions
@@ -726,6 +891,25 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
726
891
  .inline-action {
727
892
  margin-bottom: 12px;
728
893
  }
894
+ .nav-form {
895
+ display: inline-flex;
896
+ margin: 0;
897
+ }
898
+ .nav-form button {
899
+ min-height: 0;
900
+ padding: 0;
901
+ border: 0;
902
+ background: transparent;
903
+ color: inherit;
904
+ font: inherit;
905
+ font-weight: inherit;
906
+ }
907
+ .nav-form button:hover {
908
+ background: transparent;
909
+ border: 0;
910
+ color: var(--text);
911
+ text-decoration: underline;
912
+ }
729
913
  .diff-form {
730
914
  grid-template-columns: 160px 160px auto;
731
915
  width: fit-content;
@@ -788,6 +972,64 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
788
972
  display: grid;
789
973
  gap: 16px;
790
974
  }
975
+ .auth-panel {
976
+ max-width: 720px;
977
+ margin: 0 auto;
978
+ }
979
+ .auth-form .editor-fields {
980
+ grid-template-columns: minmax(220px, 1fr);
981
+ }
982
+ .auth-error,
983
+ .token-once {
984
+ border: 1px solid var(--line);
985
+ border-radius: 8px;
986
+ padding: 12px 14px;
987
+ background: var(--panel);
988
+ }
989
+ .auth-error {
990
+ color: #991b1b;
991
+ background: #fef2f2;
992
+ border-color: #fecaca;
993
+ }
994
+ .muted {
995
+ color: var(--muted);
996
+ }
997
+ .meta-card {
998
+ display: grid;
999
+ gap: 10px;
1000
+ margin-bottom: 16px;
1001
+ border: 1px solid var(--line);
1002
+ border-radius: 8px;
1003
+ padding: 16px;
1004
+ background: var(--panel);
1005
+ }
1006
+ .meta-card h2,
1007
+ .token-once h2 {
1008
+ margin: 0;
1009
+ font-size: 18px;
1010
+ }
1011
+ .data-table {
1012
+ width: 100%;
1013
+ border-collapse: collapse;
1014
+ font-size: 13px;
1015
+ }
1016
+ .data-table th,
1017
+ .data-table td {
1018
+ padding: 9px 10px;
1019
+ border-bottom: 1px solid var(--line);
1020
+ text-align: left;
1021
+ vertical-align: middle;
1022
+ }
1023
+ .data-table th {
1024
+ color: var(--faint);
1025
+ font-family: var(--mono);
1026
+ font-size: 11.5px;
1027
+ letter-spacing: 0.06em;
1028
+ text-transform: uppercase;
1029
+ }
1030
+ .data-table form {
1031
+ margin: 0;
1032
+ }
791
1033
  .editor-fields {
792
1034
  display: grid;
793
1035
  grid-template-columns: minmax(220px, 1fr) 180px 180px minmax(160px, 240px);
@@ -1573,6 +1815,13 @@ function languageSwitcher(view) {
1573
1815
  return `<span class="language-switcher">${english}${korean}</span>`;
1574
1816
  }
1575
1817
 
1818
+ function authNav(user) {
1819
+ if (!user) {
1820
+ return `<a href="/login">Sign in</a>`;
1821
+ }
1822
+ return `${user.role === "admin" ? `<a href="/admin/users">Users</a>` : ""}<a href="/account">Account</a>`;
1823
+ }
1824
+
1576
1825
  export function escapeHtml(value) {
1577
1826
  return String(value)
1578
1827
  .replaceAll("&", "&amp;")
@@ -57,6 +57,10 @@ export function requireToken({ request, url, body = {}, config = securityConfig(
57
57
  }
58
58
  }
59
59
 
60
+ export function requestToken({ request, url, body = {} }) {
61
+ return extractToken({ request, url, body });
62
+ }
63
+
60
64
  export function scanForSecrets(content) {
61
65
  const text = String(content ?? "");
62
66
  const findings = [];
@@ -230,6 +230,7 @@ function serviceConfig(options = {}) {
230
230
  apiToken: options.apiToken || "",
231
231
  shareMode: options.shareMode || "",
232
232
  allowSecrets: Boolean(options.allowSecrets),
233
+ mcpHttp: Boolean(options.mcpHttp),
233
234
  taskName: options.taskName || DEFAULT_TASK_NAME
234
235
  };
235
236
  }
@@ -251,6 +252,9 @@ function serverArgs(config, options = {}) {
251
252
  if (config.allowSecrets) {
252
253
  args.push("--allow-secrets");
253
254
  }
255
+ if (config.mcpHttp) {
256
+ args.push("--mcp-http");
257
+ }
254
258
  if (options.includeApiTokenArg && config.apiToken) {
255
259
  args.push("--api-token", config.apiToken);
256
260
  }