@miphamai/cli 0.81.6 → 0.81.8
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/README.md +9 -9
- package/bin/daemon.ts +7 -32
- package/bin/mipham.ts +43 -29
- package/package.json +5 -2
- package/skills/standard/mipham-code-setup.SKILL.md +3 -3
- package/src/agent/sub-agent.ts +12 -1
- package/src/artifacts/manifest.ts +90 -34
- package/src/artifacts/paths.ts +19 -0
- package/src/artifacts/server.ts +48 -8
- package/src/commands/project.ts +92 -12
- package/src/config/keys-manager.ts +10 -11
- package/src/config/loader.ts +82 -1
- package/src/config/preferences.ts +5 -2
- package/src/core/context.ts +10 -2
- package/src/core/cron-poller.ts +30 -6
- package/src/core/engine.ts +19 -4
- package/src/core/metrics.ts +8 -0
- package/src/core/paths.ts +79 -0
- package/src/core/permission-rules.ts +261 -17
- package/src/core/permission.ts +3 -0
- package/src/core/session-log.ts +55 -3
- package/src/core/session-store.ts +11 -1
- package/src/daemon/engine-capabilities.ts +131 -0
- package/src/daemon/index.ts +4 -1
- package/src/daemon/launch.ts +287 -0
- package/src/daemon/remote-engine.ts +2 -0
- package/src/daemon/server.ts +9 -0
- package/src/daemon/session-worker.ts +21 -3
- package/src/i18n-core/locales/en-US.json +6 -7
- package/src/i18n-core/locales/zh-CN.json +6 -7
- package/src/index.tsx +82 -2
- package/src/mcp/client.ts +4 -2
- package/src/plugin/plugin-manager.ts +17 -6
- package/src/providers/anthropic.ts +28 -2
- package/src/providers/openai-compat.ts +14 -1
- package/src/security/path.ts +6 -1
- package/src/shared/atomic-write.ts +28 -5
- package/src/shared/package-info.ts +1 -1
- package/src/shared/types.ts +24 -0
- package/src/skills/bundled-skills.ts +1 -1
- package/src/telemetry/consent.ts +209 -0
- package/src/telemetry/crash.ts +197 -0
- package/src/telemetry/endpoint.ts +82 -0
- package/src/telemetry/index.ts +153 -0
- package/src/telemetry/payload.ts +141 -0
- package/src/telemetry/queue.ts +95 -0
- package/src/telemetry/redact.ts +127 -0
- package/src/telemetry/transport.ts +81 -0
- package/src/tools/agent/workflow.ts +11 -4
- package/src/tools/artifact/artifact.ts +14 -4
- package/src/tools/exec/bash.ts +45 -21
- package/src/tools/exec/enter-worktree.ts +6 -5
- package/src/tools/exec/exit-worktree.ts +10 -5
- package/src/tools/exec/git.ts +25 -10
- package/src/tools/file/grep.ts +37 -13
- package/src/tools/file/read.ts +151 -45
- package/src/tools/scheduling/cron.ts +34 -5
- package/src/tools/system/config.ts +9 -5
- package/src/ui/app.tsx +40 -11
- package/src/ui/commands.ts +186 -45
- package/src/workflow/primitives/agent.ts +4 -2
- package/src/artifacts/versioning.ts +0 -127
- package/src/core/task-runner-tasks.json +0 -14
- package/src/core/task-runner.ts +0 -163
- package/src/skills/mipham/runtime.ts +0 -66
- package/src/skills/standard/runtime.ts +0 -62
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
# Mipham Code v0.
|
|
1
|
+
# Mipham Code v0.81.7
|
|
2
2
|
|
|
3
|
-
**Multi-model open-core intelligent coding terminal** —
|
|
3
|
+
**Multi-model open-core intelligent coding terminal** — 12 AI providers, 137 commands, 31 tools, 28 skills + marketplace, self-update. AI-assisted code generation, security auditing, MCP protocol, and extensible skills — in a single CLI.
|
|
4
4
|
|
|
5
5
|
Built by [One Mipham Corporation](https://onemipham.com) (北京华安麦逄科技有限公司) | Brand: MiphamAI
|
|
6
6
|
|
|
@@ -15,13 +15,13 @@ Requirements: **Bun 1.2+** (recommended) or **Node.js 22+**
|
|
|
15
15
|
|
|
16
16
|
## Features
|
|
17
17
|
|
|
18
|
-
- **
|
|
19
|
-
- **
|
|
20
|
-
- **
|
|
21
|
-
- **
|
|
18
|
+
- **12 AI Providers** — Anthropic Claude · OpenAI GPT · DeepSeek · Kimi (Moonshot) · Google Gemini · Doubao 豆包 · Tencent Hunyuan 混元 · Qwen 通义千问 · MiniMax(国内 / 国际)· Ollama(本地)· MiphamAI
|
|
19
|
+
- **137 Slash Commands** — Interactive command picker (↑↓ Enter Esc), intuitive command interface, zero re-learning
|
|
20
|
+
- **31 Built-in Tools** — File ops, shell execution, agent dispatch, MCP integration, web search
|
|
21
|
+
- **28 Skills + Marketplace** — 22 standard + 6 Mipham-exclusive skills. `/browse-skills` `/install-skill` community marketplace
|
|
22
22
|
- **Self-Update** — `mipham update` / `/upgrade` — one command to check, backup config, upgrade, restore. API keys preserved
|
|
23
23
|
- **Smart Recommendations** — `/recommend` analyzes your project, suggests skills, providers, and config
|
|
24
|
-
- **One-Click Config** — `/init` generates config.yml with
|
|
24
|
+
- **One-Click Config** — `/init` generates config.yml with the providers pre-populated (just replace API keys)
|
|
25
25
|
- **MCP Protocol** — Full JSON-RPC 2.0 stdio transport for external server integration
|
|
26
26
|
- **Security Hardened** — Path sandbox · SSRF protection · Bash blacklist · Permission gating · Parameter validation · command injection prevention · API key redaction
|
|
27
27
|
- **Config Resilience** — YAML error recovery, deep merge providers, auto-backup (5 copies), corruption auto-restore
|
|
@@ -34,7 +34,7 @@ Requirements: **Bun 1.2+** (recommended) or **Node.js 22+**
|
|
|
34
34
|
mipham
|
|
35
35
|
|
|
36
36
|
# First run auto-initializes config. Or:
|
|
37
|
-
/init # Generate ~/.mipham/config.yml with
|
|
37
|
+
/init # Generate ~/.mipham/config.yml with providers pre-configured
|
|
38
38
|
|
|
39
39
|
# Set API keys (env vars or config.yml)
|
|
40
40
|
export DEEPSEEK_API_KEY="sk-..."
|
|
@@ -73,7 +73,7 @@ Press **Ctrl+P** for model picker · **Shift+Tab** to cycle permission mode · *
|
|
|
73
73
|
- [Website (International)](https://mipham.ai/mipham-code)
|
|
74
74
|
- [国内站](https://onemipham.com/mipham-code)
|
|
75
75
|
- [GitHub](https://github.com/One-Mipham/mipham-code)
|
|
76
|
-
- [Product Specification](https://github.com/One-Mipham/mipham-code/blob/main/PRODUCT.md)
|
|
76
|
+
- [Product Specification (v1.0.0, 2026-06-10 snapshot)](https://github.com/One-Mipham/mipham-code/blob/main/PRODUCT.md) — frozen, not current
|
|
77
77
|
- [npm](https://www.npmjs.com/package/@miphamai/cli)
|
|
78
78
|
|
|
79
79
|
## License
|
package/bin/daemon.ts
CHANGED
|
@@ -1,40 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Mipham Code Daemon — standalone
|
|
5
|
-
*
|
|
4
|
+
* Mipham Code Daemon — standalone source-mode entry.
|
|
5
|
+
*
|
|
6
|
+
* The compiled binary reaches the same code through the hidden `__daemon`
|
|
7
|
+
* branch in bin/mipham.ts. Both call runDaemonProcess() so there is exactly
|
|
8
|
+
* one implementation of the daemon process body.
|
|
6
9
|
*
|
|
7
10
|
* Usage: bun run bin/daemon.ts [--port PORT] [--bind HOST]
|
|
8
11
|
*/
|
|
9
12
|
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
const args = process.argv.slice(2)
|
|
13
|
-
|
|
14
|
-
// Parse --port and --bind from CLI args (passed by mipham CLI)
|
|
15
|
-
for (let i = 0; i < args.length; i++) {
|
|
16
|
-
if (args[i] === '--port' && args[i + 1]) {
|
|
17
|
-
process.env.MIPHAM_PORT = args[i + 1]!
|
|
18
|
-
}
|
|
19
|
-
if (args[i] === '--bind' && args[i + 1]) {
|
|
20
|
-
process.env.MIPHAM_BIND = args[i + 1]!
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const { port } = await startDaemon()
|
|
25
|
-
|
|
26
|
-
console.log(`Daemon running on http://127.0.0.1:${port}`)
|
|
27
|
-
console.log(`PID: ${process.pid}`)
|
|
28
|
-
|
|
29
|
-
// Keep process alive
|
|
30
|
-
process.on('SIGTERM', async () => {
|
|
31
|
-
const { stopDaemon } = await import('../src/daemon/index')
|
|
32
|
-
await stopDaemon(true)
|
|
33
|
-
process.exit(0)
|
|
34
|
-
})
|
|
13
|
+
import { runDaemonProcess } from '../src/daemon/launch'
|
|
35
14
|
|
|
36
|
-
|
|
37
|
-
const { stopDaemon } = await import('../src/daemon/index')
|
|
38
|
-
await stopDaemon(true)
|
|
39
|
-
process.exit(0)
|
|
40
|
-
})
|
|
15
|
+
await runDaemonProcess()
|
package/bin/mipham.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
export {} // ensure module scope (prevents global name collisions)
|
|
8
8
|
|
|
9
|
+
import { DAEMON_ENTRY, userArgs } from '../src/daemon/launch'
|
|
10
|
+
|
|
9
11
|
async function runWorkflowCLI(): Promise<boolean> {
|
|
10
12
|
const args = process.argv.slice(2)
|
|
11
13
|
if (args[0] !== 'workflow') return false
|
|
@@ -324,8 +326,6 @@ async function runDaemonCLI(): Promise<boolean> {
|
|
|
324
326
|
|
|
325
327
|
const subcmd = args[1]
|
|
326
328
|
|
|
327
|
-
const { spawn } = await import('node:child_process')
|
|
328
|
-
|
|
329
329
|
if (subcmd === 'start') {
|
|
330
330
|
const { getDaemonStatus } = await import('../src/daemon/index')
|
|
331
331
|
const status = getDaemonStatus()
|
|
@@ -335,23 +335,13 @@ async function runDaemonCLI(): Promise<boolean> {
|
|
|
335
335
|
}
|
|
336
336
|
|
|
337
337
|
console.log('Starting daemon...')
|
|
338
|
-
const
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
})
|
|
344
|
-
child.unref()
|
|
345
|
-
|
|
346
|
-
// Wait briefly for daemon to write PID/port files
|
|
347
|
-
await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
348
|
-
|
|
349
|
-
const newStatus = getDaemonStatus()
|
|
350
|
-
if (newStatus) {
|
|
351
|
-
console.log(`Daemon started (PID: ${newStatus.pid}, Port: ${newStatus.port})`)
|
|
352
|
-
} else {
|
|
353
|
-
console.log('Daemon started (PID unknown — check `mipham daemon status`)')
|
|
338
|
+
const { startDetachedDaemon } = await import('../src/daemon/launch')
|
|
339
|
+
const launch = await startDetachedDaemon()
|
|
340
|
+
if (!launch.ok) {
|
|
341
|
+
console.error(`Failed to start daemon: ${launch.reason}`)
|
|
342
|
+
process.exit(1)
|
|
354
343
|
}
|
|
344
|
+
console.log(`Daemon started (PID: ${launch.pid}, Port: ${launch.port})`)
|
|
355
345
|
process.exit(0)
|
|
356
346
|
}
|
|
357
347
|
|
|
@@ -387,6 +377,7 @@ async function runDaemonCLI(): Promise<boolean> {
|
|
|
387
377
|
|
|
388
378
|
if (subcmd === 'restart') {
|
|
389
379
|
const { getDaemonStatus } = await import('../src/daemon/index')
|
|
380
|
+
const { startDetachedDaemon, waitForDaemonExit } = await import('../src/daemon/launch')
|
|
390
381
|
const status = getDaemonStatus()
|
|
391
382
|
if (status) {
|
|
392
383
|
try {
|
|
@@ -394,18 +385,28 @@ async function runDaemonCLI(): Promise<boolean> {
|
|
|
394
385
|
} catch {
|
|
395
386
|
// Process may have already exited
|
|
396
387
|
}
|
|
397
|
-
|
|
388
|
+
// A fixed sleep here is a guess, and the guess is load-bearing:
|
|
389
|
+
// startDetachedDaemon() opens by probing getStatus(), so a pid file the old
|
|
390
|
+
// daemon has not unlinked yet reads as "already running" — restart would
|
|
391
|
+
// then report success for a start it never performed. Wait for the old
|
|
392
|
+
// daemon to be *gone*; if it never goes, refuse instead of starting blind.
|
|
393
|
+
const waitStarted = Date.now()
|
|
394
|
+
const gone = await waitForDaemonExit()
|
|
395
|
+
if (!gone) {
|
|
396
|
+
const waitedMs = Date.now() - waitStarted
|
|
397
|
+
console.error(
|
|
398
|
+
`Refusing to restart: old daemon (PID: ${status.pid}) is still running after ${waitedMs}ms`,
|
|
399
|
+
)
|
|
400
|
+
process.exit(1)
|
|
401
|
+
}
|
|
398
402
|
}
|
|
399
403
|
|
|
400
|
-
const
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
})
|
|
406
|
-
child.unref()
|
|
407
|
-
await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
408
|
-
console.log('Daemon restarted.')
|
|
404
|
+
const launch = await startDetachedDaemon()
|
|
405
|
+
if (!launch.ok) {
|
|
406
|
+
console.error(`Failed to restart daemon: ${launch.reason}`)
|
|
407
|
+
process.exit(1)
|
|
408
|
+
}
|
|
409
|
+
console.log(`Daemon restarted (PID: ${launch.pid}, Port: ${launch.port})`)
|
|
409
410
|
process.exit(0)
|
|
410
411
|
}
|
|
411
412
|
|
|
@@ -1095,6 +1096,19 @@ async function runInitCLI(): Promise<boolean> {
|
|
|
1095
1096
|
}
|
|
1096
1097
|
|
|
1097
1098
|
async function main() {
|
|
1099
|
+
// ── Hidden daemon entry ────────────────────────────────────────────────
|
|
1100
|
+
// `daemon start` re-execs this same program with `__daemon` (see
|
|
1101
|
+
// src/daemon/launch.ts). This must come first: everything below assumes an
|
|
1102
|
+
// interactive TTY — the stty block would run against a detached child whose
|
|
1103
|
+
// stdio is the daemon log. The sentinel is stripped here because the two
|
|
1104
|
+
// entry points have different argv shapes (see runDaemonProcess).
|
|
1105
|
+
const argv = userArgs(process.argv)
|
|
1106
|
+
if (argv[0] === DAEMON_ENTRY) {
|
|
1107
|
+
const { runDaemonProcess } = await import('../src/daemon/launch')
|
|
1108
|
+
await runDaemonProcess(argv.slice(1))
|
|
1109
|
+
return
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1098
1112
|
// ── Deleted-cwd guard ──────────────────────────────────────────────────
|
|
1099
1113
|
// `process.cwd()` throws ENOENT when the directory the process was launched
|
|
1100
1114
|
// from no longer exists (e.g. a removed git worktree). Print a clear message
|
|
@@ -1309,4 +1323,4 @@ Docs: https://onemipham.com/mipham-code
|
|
|
1309
1323
|
}
|
|
1310
1324
|
}
|
|
1311
1325
|
|
|
1312
|
-
main()
|
|
1326
|
+
void main()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miphamai/cli",
|
|
3
|
-
"version": "0.81.
|
|
3
|
+
"version": "0.81.8",
|
|
4
4
|
"description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -40,7 +40,8 @@
|
|
|
40
40
|
"typecheck": "tsc --noEmit",
|
|
41
41
|
"test": "vitest run",
|
|
42
42
|
"knip": "knip --production --no-progress --no-exit-code",
|
|
43
|
-
"coverage": "vitest run --coverage
|
|
43
|
+
"coverage": "vitest run --coverage",
|
|
44
|
+
"mutate": "stryker run"
|
|
44
45
|
},
|
|
45
46
|
"dependencies": {
|
|
46
47
|
"@larksuiteoapi/node-sdk": "^1.73.0",
|
|
@@ -53,6 +54,8 @@
|
|
|
53
54
|
},
|
|
54
55
|
"devDependencies": {
|
|
55
56
|
"@mipham/shared": "workspace:*",
|
|
57
|
+
"@stryker-mutator/core": "10.0.0",
|
|
58
|
+
"@stryker-mutator/vitest-runner": "10.0.0",
|
|
56
59
|
"@types/bun": "^1.4.2",
|
|
57
60
|
"@types/node": "^22.19.19",
|
|
58
61
|
"@types/react": "^19.3.0",
|
|
@@ -286,10 +286,10 @@ providers:
|
|
|
286
286
|
|
|
287
287
|
### 5.1 — Built-in Skills
|
|
288
288
|
|
|
289
|
-
Mipham Code ships with
|
|
289
|
+
Mipham Code ships with 28 built-in skills loaded automatically:
|
|
290
290
|
|
|
291
|
-
- **Standard (
|
|
292
|
-
- **Mipham (
|
|
291
|
+
- **Standard (22)**: code-review, codebase-design, compassionate-communication, debug-loop, doc-generator, domain-modeling, github-ops, grill-with-docs, implement, memory, mipham-code-setup, research, safe-coding, security-review, self-review, superpower, tdd, to-spec, triage, trim-process-prose, web-access, web-search
|
|
292
|
+
- **Mipham (6)**: doc-sync, om-artifact, om-model-optimize, om-security, save-to-wiki, self-audit
|
|
293
293
|
|
|
294
294
|
### 5.2 — Community Skills
|
|
295
295
|
|
package/src/agent/sub-agent.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type { PermissionSystem } from '../core/permission'
|
|
|
10
10
|
import { AgentExperience } from './agent-experience'
|
|
11
11
|
import { PatternAnalyzer } from './pattern-analyzer.js'
|
|
12
12
|
import { getWorkspaceTrust } from '../core/workspace-trust'
|
|
13
|
+
import { recordToolCall } from '../telemetry/index'
|
|
13
14
|
import type { ExperienceRuleEngine } from '../core/rule-engine.js'
|
|
14
15
|
|
|
15
16
|
// Singleton instances (created lazily)
|
|
@@ -85,7 +86,7 @@ export class SubAgent {
|
|
|
85
86
|
// Register completion callback for hook firing
|
|
86
87
|
bgRegistry.onComplete(taskId, (task) => {
|
|
87
88
|
if (this.hookEngine) {
|
|
88
|
-
this.hookEngine.executeSubagentStop(
|
|
89
|
+
void this.hookEngine.executeSubagentStop(
|
|
89
90
|
agentType,
|
|
90
91
|
description,
|
|
91
92
|
taskId,
|
|
@@ -397,6 +398,15 @@ export class SubAgent {
|
|
|
397
398
|
throw new DOMException('Aborted', 'AbortError')
|
|
398
399
|
}
|
|
399
400
|
|
|
401
|
+
// Counted here, at dispatch, mirroring `Engine.executeTool`'s entry
|
|
402
|
+
// increment — this loop bypasses `executeTool` entirely and
|
|
403
|
+
// reimplements the hook/permission steps itself, so without this line
|
|
404
|
+
// every tool call made inside a sub-agent (and therefore inside a
|
|
405
|
+
// workflow, which derives sub-agents) is invisible to the metrics
|
|
406
|
+
// registry. Same failure shape as the two "one of two paths wired"
|
|
407
|
+
// bugs this repo has already recorded.
|
|
408
|
+
recordToolCall(tu.name)
|
|
409
|
+
|
|
400
410
|
const tool = this.toolRegistry.get(tu.name)
|
|
401
411
|
if (!tool) {
|
|
402
412
|
currentMessages.push({
|
|
@@ -490,6 +500,7 @@ export class SubAgent {
|
|
|
490
500
|
content: displayResult.success
|
|
491
501
|
? displayResult.content
|
|
492
502
|
: displayResult.error || displayResult.content,
|
|
503
|
+
...(displayResult.success ? {} : { is_error: true }),
|
|
493
504
|
},
|
|
494
505
|
],
|
|
495
506
|
})
|
|
@@ -1,47 +1,95 @@
|
|
|
1
|
-
import {
|
|
2
|
-
readFileSync,
|
|
3
|
-
writeFileSync,
|
|
4
|
-
existsSync,
|
|
5
|
-
mkdirSync,
|
|
6
|
-
renameSync,
|
|
7
|
-
copyFileSync,
|
|
8
|
-
unlinkSync,
|
|
9
|
-
} from 'node:fs'
|
|
1
|
+
import { readFileSync, existsSync, mkdirSync, renameSync, copyFileSync, unlinkSync } from 'node:fs'
|
|
10
2
|
import { join } from 'node:path'
|
|
3
|
+
import { atomicWriteFileSync } from '../shared/atomic-write'
|
|
11
4
|
import type { ArtifactManifest, ArtifactEntry } from '../shared/types'
|
|
12
5
|
|
|
6
|
+
function emptyManifest(): ArtifactManifest {
|
|
7
|
+
return { version: 1, artifacts: [] }
|
|
8
|
+
}
|
|
9
|
+
|
|
13
10
|
/**
|
|
14
11
|
* Read the artifact manifest from disk, or return an empty one if it doesn't exist.
|
|
12
|
+
*
|
|
13
|
+
* An unreadable index also yields an empty manifest — the callers here only *show*
|
|
14
|
+
* artifacts (gallery, `/artifact list`), and showing none beats throwing at them.
|
|
15
|
+
* Writers must not go through this path: see `readManifestForUpdate`.
|
|
15
16
|
*/
|
|
16
17
|
export function readManifest(dir: string): ArtifactManifest {
|
|
17
18
|
const path = join(dir, 'index.json')
|
|
18
19
|
if (!existsSync(path)) {
|
|
19
|
-
return
|
|
20
|
+
return emptyManifest()
|
|
20
21
|
}
|
|
21
22
|
try {
|
|
22
23
|
return JSON.parse(readFileSync(path, 'utf-8'))
|
|
23
24
|
} catch {
|
|
24
|
-
return
|
|
25
|
+
return emptyManifest()
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Read the manifest for a caller that is about to write it back.
|
|
31
|
+
*
|
|
32
|
+
* The difference from `readManifest` is what happens when the file exists but does
|
|
33
|
+
* not parse: a writer must not treat that as "empty", because writing back an
|
|
34
|
+
* empty manifest replaces every other artifact's entry with nothing — the files
|
|
35
|
+
* stay on disk, but the gallery and `/artifact list` lose them. So the unreadable
|
|
36
|
+
* file is renamed aside (bytes kept, recoverable by hand) and reported, and the
|
|
37
|
+
* caller decides what to tell the user.
|
|
38
|
+
*/
|
|
39
|
+
function readManifestForUpdate(dir: string): {
|
|
40
|
+
manifest: ArtifactManifest
|
|
41
|
+
quarantined?: string
|
|
42
|
+
} {
|
|
43
|
+
const path = join(dir, 'index.json')
|
|
44
|
+
if (!existsSync(path)) return { manifest: emptyManifest() }
|
|
45
|
+
try {
|
|
46
|
+
return { manifest: JSON.parse(readFileSync(path, 'utf-8')) }
|
|
47
|
+
} catch {
|
|
48
|
+
const quarantined = `${path}.corrupt-${new Date().toISOString().replace(/[:.]/g, '-')}`
|
|
49
|
+
// If this rename fails we let it throw: the alternative is overwriting the
|
|
50
|
+
// only copy of the index with a manifest built from nothing.
|
|
51
|
+
renameSync(path, quarantined)
|
|
52
|
+
return { manifest: emptyManifest(), quarantined }
|
|
25
53
|
}
|
|
26
54
|
}
|
|
27
55
|
|
|
28
56
|
/**
|
|
29
57
|
* Write the manifest to disk, creating parent directories as needed.
|
|
58
|
+
*
|
|
59
|
+
* Atomic: a crash mid-write used to leave a truncated `index.json`, which is
|
|
60
|
+
* exactly the corruption `readManifestForUpdate` then has to quarantine.
|
|
30
61
|
*/
|
|
31
62
|
export function writeManifest(dir: string, manifest: ArtifactManifest): void {
|
|
32
63
|
mkdirSync(dir, { recursive: true })
|
|
33
|
-
|
|
64
|
+
atomicWriteFileSync(join(dir, 'index.json'), JSON.stringify(manifest, null, 2), {
|
|
65
|
+
mode: 0o644,
|
|
66
|
+
})
|
|
34
67
|
}
|
|
35
68
|
|
|
36
69
|
/**
|
|
37
70
|
* Add an entry to the manifest and persist it.
|
|
38
|
-
*
|
|
71
|
+
*
|
|
72
|
+
* An entry is identified by `name` **and** `sessionId`, matching how the tool
|
|
73
|
+
* looks one up: the manifest is a single global `index.json` holding every
|
|
74
|
+
* session's artifacts, so keying on the name alone made two sessions publishing
|
|
75
|
+
* the same name overwrite each other's entry — the loser's file stayed on disk
|
|
76
|
+
* but vanished from the index.
|
|
77
|
+
*
|
|
78
|
+
* Returns the written manifest plus, when the previous index was unreadable, the
|
|
79
|
+
* path its bytes were moved to — the caller is expected to say so rather than
|
|
80
|
+
* let an artifact appear to publish cleanly over a lost index.
|
|
39
81
|
*/
|
|
40
|
-
export function addToManifest(
|
|
41
|
-
|
|
82
|
+
export function addToManifest(
|
|
83
|
+
dir: string,
|
|
84
|
+
entry: ArtifactEntry,
|
|
85
|
+
port?: number,
|
|
86
|
+
): { manifest: ArtifactManifest; quarantined?: string } {
|
|
87
|
+
const { manifest, quarantined } = readManifestForUpdate(dir)
|
|
42
88
|
if (port !== undefined) manifest.port = port
|
|
43
89
|
|
|
44
|
-
const idx = manifest.artifacts.findIndex(
|
|
90
|
+
const idx = manifest.artifacts.findIndex(
|
|
91
|
+
(a) => a.name === entry.name && a.sessionId === entry.sessionId,
|
|
92
|
+
)
|
|
45
93
|
if (idx >= 0) {
|
|
46
94
|
manifest.artifacts[idx] = entry
|
|
47
95
|
} else {
|
|
@@ -49,7 +97,7 @@ export function addToManifest(dir: string, entry: ArtifactEntry, port?: number):
|
|
|
49
97
|
}
|
|
50
98
|
|
|
51
99
|
writeManifest(dir, manifest)
|
|
52
|
-
return manifest
|
|
100
|
+
return { manifest, quarantined }
|
|
53
101
|
}
|
|
54
102
|
|
|
55
103
|
/**
|
|
@@ -64,31 +112,39 @@ export function getSessionArtifacts(dir: string, sessionId: string): ArtifactEnt
|
|
|
64
112
|
* Archive an existing artifact file by renaming it with a version tag.
|
|
65
113
|
* e.g. dashboard.html → dashboard.v1.html, dashboard.v1.html → dashboard.v2.html.
|
|
66
114
|
*
|
|
67
|
-
* Returns the version tag assigned to the archived file
|
|
115
|
+
* Returns the version tag assigned to the archived file, or `undefined` when
|
|
116
|
+
* there was nothing to archive — in which case the manifest is left untouched.
|
|
117
|
+
*
|
|
118
|
+
* 「没归档就什么都不记」是刻意的:源文件找不到时照样推进版本号、往 `versions` 里
|
|
119
|
+
* 塞一个标签,等于在 manifest 里留一版磁盘上并不存在的版本。走到那条路并不难
|
|
120
|
+
* —— 条目按 name 找、文件按 `<session>/<name><ext>` 找,把同一个名字从 html 改
|
|
121
|
+
* 成 svg 就错开了。宁可少记一版,也不能记一版假的。
|
|
68
122
|
*/
|
|
69
|
-
export function archiveVersion(dir: string, entry: ArtifactEntry): string {
|
|
70
|
-
const manifest = readManifest(dir)
|
|
71
|
-
const versionCount = (entry.versionCount || 1) + 1
|
|
123
|
+
export function archiveVersion(dir: string, entry: ArtifactEntry): string | undefined {
|
|
72
124
|
const ext = entry.type === 'svg' ? '.svg' : '.html'
|
|
73
|
-
const versionTag = `v${versionCount}`
|
|
74
|
-
|
|
75
|
-
// Rename the current file to a versioned copy
|
|
76
125
|
const baseName = entry.name
|
|
77
126
|
const currentPath = join(dir, entry.sessionId, `${baseName}${ext}`)
|
|
127
|
+
|
|
128
|
+
if (!existsSync(currentPath)) return undefined
|
|
129
|
+
|
|
130
|
+
const versionCount = (entry.versionCount || 1) + 1
|
|
131
|
+
const versionTag = `v${versionCount}`
|
|
78
132
|
const archivedPath = join(dir, entry.sessionId, `${baseName}.${versionTag}${ext}`)
|
|
79
133
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
unlinkSync(currentPath)
|
|
87
|
-
}
|
|
134
|
+
try {
|
|
135
|
+
renameSync(currentPath, archivedPath)
|
|
136
|
+
} catch {
|
|
137
|
+
// If rename fails (e.g. cross-device), copy instead
|
|
138
|
+
copyFileSync(currentPath, archivedPath)
|
|
139
|
+
unlinkSync(currentPath)
|
|
88
140
|
}
|
|
89
141
|
|
|
90
|
-
// Update manifest entry
|
|
91
|
-
|
|
142
|
+
// Update manifest entry — keyed on name *and* session, like every other
|
|
143
|
+
// lookup here: a same-named artifact in another session is a different one.
|
|
144
|
+
const manifest = readManifest(dir)
|
|
145
|
+
const artifact = manifest.artifacts.find(
|
|
146
|
+
(a) => a.name === entry.name && a.sessionId === entry.sessionId,
|
|
147
|
+
)
|
|
92
148
|
if (artifact) {
|
|
93
149
|
const versions = artifact.versions || ['v1']
|
|
94
150
|
versions.push(versionTag)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { ARTIFACTS_DIR, MIPHAM_DIR } from '../shared/constants'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The one place that decides *where on disk an artifact lives*.
|
|
6
|
+
*
|
|
7
|
+
* Three sites need to agree on this: the Artifact tool (writes the file and the
|
|
8
|
+
* manifest), the ArtifactServer (serves files and builds the gallery from the
|
|
9
|
+
* manifest rooted here), and `/artifact list` (reads the manifest). Each of them
|
|
10
|
+
* computing the path itself is what produced the original bug: the tool wrote
|
|
11
|
+
* `<cwd>/artifacts/...` while the server served `<cwd>/.mipham/artifacts/...`,
|
|
12
|
+
* so the URL the tool reported was a guaranteed 404 — the two halves never met.
|
|
13
|
+
*
|
|
14
|
+
* Keeping them joined here means a change moves all three together; the guard
|
|
15
|
+
* test asserts the *behaviour* (the reported URL resolves), not the literal.
|
|
16
|
+
*/
|
|
17
|
+
export function artifactsRoot(cwd: string): string {
|
|
18
|
+
return join(cwd, MIPHAM_DIR, ARTIFACTS_DIR)
|
|
19
|
+
}
|
package/src/artifacts/server.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { createServer, type Server } from 'node:http'
|
|
2
|
-
import {
|
|
2
|
+
import type { Socket } from 'node:net'
|
|
3
|
+
import { createReadStream, existsSync, readFileSync, statSync } from 'node:fs'
|
|
3
4
|
import { join, normalize, extname } from 'node:path'
|
|
4
5
|
import { ARTIFACT_ALLOWED_EXTENSIONS } from '../shared/constants'
|
|
5
6
|
import { readManifest } from './manifest'
|
|
6
|
-
import { ArtifactVersioning } from './versioning'
|
|
7
7
|
import type { ArtifactEntry } from '../shared/types'
|
|
8
8
|
import { getMetrics } from '../core/metrics'
|
|
9
9
|
|
|
@@ -29,12 +29,11 @@ export class ArtifactServer {
|
|
|
29
29
|
private started = false
|
|
30
30
|
private sseClients: SseClient[] = []
|
|
31
31
|
private sseIdCounter = 0
|
|
32
|
-
private
|
|
32
|
+
private sockets = new Set<Socket>()
|
|
33
33
|
|
|
34
34
|
constructor(artifactsDir: string, preferredPort: number) {
|
|
35
35
|
this.artifactsDir = artifactsDir
|
|
36
36
|
this.port = preferredPort
|
|
37
|
-
this.versioning = new ArtifactVersioning(artifactsDir)
|
|
38
37
|
}
|
|
39
38
|
|
|
40
39
|
/** Notify all connected SSE clients to reload. Called after artifact changes. */
|
|
@@ -82,6 +81,12 @@ export class ArtifactServer {
|
|
|
82
81
|
this.server = null
|
|
83
82
|
this.started = false
|
|
84
83
|
}
|
|
84
|
+
|
|
85
|
+
// `close()` only stops *accepting* — sockets already established (a browser's
|
|
86
|
+
// keep-alive, an SSE stream) stay open and keep being served, so a "stopped"
|
|
87
|
+
// server answers on the old port until the client hangs up. Destroy them.
|
|
88
|
+
for (const socket of this.sockets) socket.destroy()
|
|
89
|
+
this.sockets.clear()
|
|
85
90
|
}
|
|
86
91
|
|
|
87
92
|
getPort(): number {
|
|
@@ -114,6 +119,10 @@ export class ArtifactServer {
|
|
|
114
119
|
const srv = createServer((req, res) => {
|
|
115
120
|
this.handleRequest(req, res)
|
|
116
121
|
})
|
|
122
|
+
srv.on('connection', (socket) => {
|
|
123
|
+
this.sockets.add(socket)
|
|
124
|
+
socket.on('close', () => this.sockets.delete(socket))
|
|
125
|
+
})
|
|
117
126
|
srv.on('error', reject)
|
|
118
127
|
srv.listen(port, () => {
|
|
119
128
|
this.server = srv
|
|
@@ -216,8 +225,31 @@ export class ArtifactServer {
|
|
|
216
225
|
})
|
|
217
226
|
}
|
|
218
227
|
|
|
219
|
-
/**
|
|
228
|
+
/**
|
|
229
|
+
* Per-artifact SSE stream: pushes the artifact's content to connected browsers
|
|
230
|
+
* every 500ms, so a page can follow an artifact the AI rewrites in place.
|
|
231
|
+
*
|
|
232
|
+
* Resolves the file through `resolveFile` — the same coordinate the gallery
|
|
233
|
+
* links to and the same traversal guard the static path uses — and 404s when no
|
|
234
|
+
* artifact carries that name, instead of holding a stream open on a file that
|
|
235
|
+
* does not exist.
|
|
236
|
+
*/
|
|
220
237
|
private handleNameSse(name: string, res: any): void {
|
|
238
|
+
const entry = readManifest(this.artifactsDir).artifacts.find((a) => a.name === name)
|
|
239
|
+
if (!entry) {
|
|
240
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' })
|
|
241
|
+
res.end(`No artifact named "${name}"`)
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const ext = entry.type === 'svg' ? '.svg' : '.html'
|
|
246
|
+
const { filePath, error, status } = this.resolveFile(`/${entry.sessionId}/${entry.name}${ext}`)
|
|
247
|
+
if (error) {
|
|
248
|
+
res.writeHead(status || 404, { 'Content-Type': 'text/plain' })
|
|
249
|
+
res.end(error)
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
|
|
221
253
|
res.writeHead(200, {
|
|
222
254
|
'Content-Type': 'text/event-stream',
|
|
223
255
|
'Cache-Control': 'no-cache',
|
|
@@ -225,10 +257,18 @@ export class ArtifactServer {
|
|
|
225
257
|
'Access-Control-Allow-Origin': '*',
|
|
226
258
|
})
|
|
227
259
|
|
|
260
|
+
let last: string | null = null
|
|
228
261
|
const interval = setInterval(() => {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
262
|
+
try {
|
|
263
|
+
const content = readFileSync(filePath, 'utf-8')
|
|
264
|
+
// Only on change: without this the stream re-sends the whole file every
|
|
265
|
+
// 500ms per client for as long as the tab stays open.
|
|
266
|
+
if (content !== last) {
|
|
267
|
+
last = content
|
|
268
|
+
res.write(`data: ${JSON.stringify({ type: 'update', name, content })}\n\n`)
|
|
269
|
+
}
|
|
270
|
+
} catch {
|
|
271
|
+
// Momentarily absent (archiving renames it) — skip this tick, try the next.
|
|
232
272
|
}
|
|
233
273
|
}, 500)
|
|
234
274
|
|