one-code-extension 0.4.0 → 0.5.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/README.md +219 -219
- package/agents/general-purpose.md +2 -2
- package/docs/guide/README.md +2 -1
- package/docs/guide/bring-your-claude-code-setup.md +60 -9
- package/docs/guide/configuration.md +48 -14
- package/docs/guide/differences-from-claude-code.md +32 -23
- package/docs/guide/doctor.md +20 -13
- package/docs/guide/hooks.md +18 -3
- package/docs/guide/installation.md +27 -4
- package/docs/guide/permissions-modes-and-auto-mode.md +225 -49
- package/docs/guide/providers-and-models.md +137 -54
- package/docs/guide/reference.md +27 -15
- package/docs/guide/sessions-and-context.md +69 -16
- package/docs/guide/skills-plugins-and-mcp.md +57 -20
- package/docs/guide/subagents-and-workflows.md +95 -29
- package/docs/guide/tasks-and-background-work.md +101 -26
- package/docs/guide/terminal-interface.md +24 -12
- package/docs/guide/tools.md +46 -5
- package/docs/guide/troubleshooting.md +13 -9
- package/docs/guide/windows.md +66 -34
- package/extensions/artifacts/gallery.ts +227 -0
- package/extensions/artifacts/index.ts +288 -0
- package/extensions/artifacts/store.ts +570 -0
- package/extensions/artifacts/viewer.ts +199 -0
- package/extensions/ask-user/description.ts +67 -0
- package/extensions/ask-user/dialogs.ts +80 -0
- package/extensions/ask-user/index.ts +37 -33
- package/extensions/ask-user/questions.ts +32 -7
- package/extensions/ask-user/widget.ts +45 -25
- package/extensions/auto-mode/actions.ts +6 -0
- package/extensions/auto-mode/cache.ts +88 -0
- package/extensions/auto-mode/classifier-prompt.ts +5 -4
- package/extensions/auto-mode/classifier.ts +290 -65
- package/extensions/auto-mode/config.ts +72 -20
- package/extensions/auto-mode/decision-log.ts +2 -0
- package/extensions/auto-mode/defaults.ts +1 -1
- package/extensions/auto-mode/denials.ts +123 -0
- package/extensions/auto-mode/git-checkout-programs.ts +187 -0
- package/extensions/auto-mode/git-status-meta.ts +159 -0
- package/extensions/auto-mode/history.ts +189 -0
- package/extensions/auto-mode/instructions.ts +58 -52
- package/extensions/auto-mode/model-select.ts +159 -134
- package/extensions/auto-mode/paths.ts +57 -5
- package/extensions/auto-mode/pause.ts +57 -2
- package/extensions/auto-mode/prompt.ts +37 -21
- package/extensions/auto-mode/read-only-options.ts +724 -0
- package/extensions/auto-mode/resolved-paths-meta.ts +133 -0
- package/extensions/auto-mode/rules.ts +18 -1
- package/extensions/auto-mode/safety-floor.ts +361 -18
- package/extensions/auto-mode/setup-run.ts +20 -14
- package/extensions/auto-mode/shell-analysis.ts +764 -490
- package/extensions/auto-mode/shell-parse.ts +885 -0
- package/extensions/auto-mode/system-note.ts +33 -0
- package/extensions/auto-mode/transcript.ts +80 -51
- package/extensions/background/cron.ts +515 -0
- package/extensions/background/index.ts +514 -236
- package/extensions/background/loop-fire.ts +136 -0
- package/extensions/background/loop-preamble.ts +59 -0
- package/extensions/background/loop-skill.ts +163 -0
- package/extensions/background/monitor-batch.ts +53 -0
- package/extensions/background/noop-fold.ts +128 -0
- package/extensions/background/switch-guard.ts +52 -0
- package/extensions/background/wakeup.ts +159 -55
- package/extensions/bash/background.ts +53 -17
- package/extensions/bash/child.ts +7 -0
- package/extensions/bash/description.ts +188 -0
- package/extensions/bash/guards.ts +15 -67
- package/extensions/bash/index.ts +88 -35
- package/extensions/branding/compaction-keep.ts +63 -0
- package/extensions/branding/declined.ts +22 -0
- package/extensions/branding/index.ts +133 -9
- package/extensions/branding/prompt-editor.ts +53 -6
- package/extensions/branding/queued-edit.ts +31 -0
- package/extensions/branding/replaced-builtins.ts +68 -0
- package/extensions/branding/startup.ts +33 -71
- package/extensions/btw/index.ts +229 -71
- package/extensions/btw/panel.ts +228 -100
- package/extensions/btw/prompt.ts +41 -4
- package/extensions/claude-compat/index.ts +7 -11
- package/extensions/claude-context/index.ts +446 -58
- package/extensions/compaction/cut.ts +157 -0
- package/extensions/compaction/index.ts +216 -75
- package/extensions/compaction/kept-thinking.ts +62 -0
- package/extensions/compaction/prompt.ts +15 -12
- package/extensions/context-budget/index.ts +24 -5
- package/extensions/context-management/index.ts +25 -0
- package/extensions/doctor/build.ts +59 -8
- package/extensions/doctor/builtins.ts +84 -0
- package/extensions/doctor/cli.ts +7 -1
- package/extensions/doctor/compat.ts +88 -23
- package/extensions/doctor/dependencies.ts +41 -5
- package/extensions/doctor/fix-prompt.ts +9 -2
- package/extensions/doctor/index.ts +102 -58
- package/extensions/doctor/models.ts +102 -63
- package/extensions/doctor/presets.ts +42 -27
- package/extensions/doctor/report.ts +19 -2
- package/extensions/doctor/viewer.ts +49 -27
- package/extensions/effort/index.ts +72 -19
- package/extensions/effort/settings.ts +21 -0
- package/extensions/effort/slider.ts +24 -20
- package/extensions/exit/index.ts +32 -1
- package/extensions/file-tracker/index.ts +337 -26
- package/extensions/file-tracker/replay.ts +96 -10
- package/extensions/file-tracker/restore.ts +63 -0
- package/extensions/file-tracker/results.ts +66 -0
- package/extensions/file-tracker/shell-reads.ts +211 -0
- package/extensions/file-tracker/tracker.ts +32 -15
- package/extensions/footer/footer-line.ts +45 -24
- package/extensions/footer/index.ts +47 -2
- package/extensions/footer/pr.ts +34 -5
- package/extensions/hooks/compaction.ts +7 -0
- package/extensions/hooks/executor.ts +32 -30
- package/extensions/hooks/index.ts +198 -31
- package/extensions/hooks/matcher.ts +3 -0
- package/extensions/hooks/protocol.ts +41 -3
- package/extensions/hooks/settings.ts +50 -19
- package/extensions/hooks/subagent-bridge.ts +2 -0
- package/extensions/hooks/trust.ts +13 -5
- package/extensions/idle-compact/index.ts +119 -0
- package/extensions/idle-compact/policy.ts +143 -0
- package/extensions/init/index.ts +11 -10
- package/extensions/init/prompt.ts +25 -32
- package/extensions/interrupted/index.ts +13 -1
- package/extensions/kept-thinking/index.ts +27 -0
- package/extensions/lib/agent-cron.ts +130 -0
- package/extensions/lib/agent-loader.ts +15 -1
- package/extensions/lib/agent-view.ts +85 -0
- package/extensions/lib/anthropic-payload.ts +82 -1
- package/extensions/lib/anthropic-server-call.ts +230 -0
- package/extensions/lib/anthropic-server-tools.ts +485 -0
- package/extensions/lib/app-launch.mjs +94 -0
- package/extensions/lib/argument-hints.ts +190 -0
- package/extensions/lib/atomic-write.ts +6 -2
- package/extensions/lib/auto-memory.ts +75 -0
- package/extensions/lib/bash-parser.ts +84 -0
- package/extensions/lib/btw-fork.ts +93 -0
- package/extensions/lib/child-extensions.ts +42 -0
- package/extensions/lib/child-writes.ts +45 -0
- package/extensions/lib/claude-context.ts +515 -100
- package/extensions/lib/claude-external-includes.ts +68 -0
- package/extensions/lib/claude-id.ts +22 -0
- package/extensions/lib/claude-rules.ts +323 -0
- package/extensions/lib/claude-settings.ts +76 -4
- package/extensions/lib/compaction-boundary.ts +36 -0
- package/extensions/lib/compaction-keep.mjs +83 -0
- package/extensions/lib/config-mode.ts +112 -0
- package/extensions/lib/consent-dialogs.ts +63 -0
- package/extensions/lib/consent-preview.ts +5 -1
- package/extensions/lib/consent-stores.ts +48 -0
- package/extensions/lib/context-facts.ts +78 -0
- package/extensions/lib/context-stack.ts +81 -0
- package/extensions/lib/deferred.ts +219 -25
- package/extensions/lib/dock-rows.ts +60 -0
- package/extensions/lib/empty-tool-result.ts +27 -0
- package/extensions/lib/environment-block.ts +76 -0
- package/extensions/lib/fetch-timeout.ts +24 -6
- package/extensions/{system-prompt → lib}/git-status.ts +51 -13
- package/extensions/lib/git.ts +75 -13
- package/extensions/lib/grammars/LICENSE-tree-sitter-bash +21 -0
- package/extensions/lib/grammars/README.md +2 -0
- package/extensions/lib/grammars/tree-sitter-bash.wasm +0 -0
- package/extensions/lib/guide-docs.ts +45 -0
- package/extensions/lib/headless-output.ts +46 -0
- package/extensions/lib/hook-gate.ts +7 -7
- package/extensions/lib/interrupt.ts +20 -4
- package/extensions/lib/key-input.ts +455 -0
- package/extensions/lib/keys.ts +14 -1
- package/extensions/lib/local-command.ts +47 -13
- package/extensions/lib/mcp-overrides.ts +1 -1
- package/extensions/lib/mcp-share.ts +46 -0
- package/extensions/lib/memory-content.ts +146 -0
- package/extensions/lib/memory-index.ts +18 -0
- package/extensions/lib/memory.ts +134 -123
- package/extensions/lib/model-catalog/huggingface.json +174 -0
- package/extensions/lib/model-catalog/models-dev.json +18297 -0
- package/extensions/lib/model-catalog/openrouter.json +5252 -0
- package/extensions/lib/model-catalog-data.ts +513 -0
- package/extensions/lib/model-catalog.ts +462 -0
- package/extensions/lib/model-choice-warnings.ts +137 -0
- package/extensions/lib/model-pairings.ts +152 -0
- package/extensions/{auto-mode → lib}/model-picker.ts +26 -43
- package/extensions/lib/model-policy.ts +112 -15
- package/extensions/lib/model-tier.ts +283 -355
- package/extensions/lib/model-unusable.ts +1 -1
- package/extensions/lib/newer-model.ts +100 -0
- package/extensions/lib/notices.ts +87 -0
- package/extensions/lib/notifications.ts +308 -64
- package/extensions/lib/one-code-settings.ts +74 -8
- package/extensions/lib/open-browser.ts +63 -0
- package/extensions/lib/openrouter-generation.ts +94 -0
- package/extensions/lib/operation-deadline.ts +30 -0
- package/extensions/lib/original-command.ts +4 -4
- package/extensions/lib/package-version.ts +1 -1
- package/extensions/lib/panel-focus.ts +134 -0
- package/extensions/lib/paths.ts +17 -2
- package/extensions/lib/permission-gate.ts +72 -6
- package/extensions/lib/persisted-output.ts +60 -8
- package/extensions/lib/pi-ai-estimate.ts +19 -3
- package/extensions/lib/pi-version.ts +21 -2
- package/extensions/lib/plan-mode-channels.ts +20 -5
- package/extensions/lib/plugins.ts +26 -17
- package/extensions/lib/powershell-parse-script.ts +208 -0
- package/extensions/lib/powershell-parser.ts +344 -0
- package/extensions/lib/process-tree.ts +131 -8
- package/extensions/lib/prompt-options.ts +82 -0
- package/extensions/lib/queued-delivery.ts +44 -0
- package/extensions/lib/reminders.ts +364 -58
- package/extensions/lib/replaced-builtins.mjs +199 -0
- package/extensions/lib/replay-call.ts +70 -0
- package/extensions/lib/request-replay.ts +439 -0
- package/extensions/lib/scratchpad.ts +81 -14
- package/extensions/lib/send-now-channel.ts +7 -0
- package/extensions/lib/session-model-tier.ts +51 -0
- package/extensions/lib/session-work.ts +64 -0
- package/extensions/lib/settings-channels.ts +4 -4
- package/extensions/lib/shell-exit-semantics.ts +170 -0
- package/extensions/lib/shell-quote.ts +11 -0
- package/extensions/lib/shell-result.ts +70 -0
- package/extensions/lib/shell-spawn.ts +70 -6
- package/extensions/lib/shell-tool.ts +75 -25
- package/extensions/lib/side-call-cache.ts +124 -0
- package/extensions/lib/side-call-run.ts +96 -0
- package/extensions/lib/side-call-usage.ts +38 -0
- package/extensions/lib/side-call.ts +13 -1
- package/extensions/lib/single-flight.ts +18 -0
- package/extensions/lib/skill-body.ts +42 -0
- package/extensions/lib/skill-scan.ts +63 -24
- package/extensions/lib/spill-file.ts +140 -0
- package/extensions/lib/subagent-handback.ts +113 -0
- package/extensions/lib/system-role.ts +156 -0
- package/extensions/lib/terminal-text.ts +114 -0
- package/extensions/lib/text-width.ts +47 -16
- package/extensions/lib/timer-ops.ts +20 -0
- package/extensions/lib/tool-additions.ts +149 -0
- package/extensions/lib/tool-call-corruption.ts +134 -0
- package/extensions/lib/tool-path.ts +66 -0
- package/extensions/lib/tool-variants.ts +69 -0
- package/extensions/lib/tui-render.ts +255 -27
- package/extensions/lib/turn-budget-layout.ts +143 -0
- package/extensions/lib/update-check.mjs +15 -2
- package/extensions/lib/usage-bus.ts +1 -1
- package/extensions/lib/user-denial.ts +38 -0
- package/extensions/lib/workspace-channel.ts +16 -0
- package/extensions/lib/worktree-channel.ts +31 -3
- package/extensions/lib/worktree-isolation.ts +40 -10
- package/extensions/lsp/client.ts +8 -9
- package/extensions/lsp/index.ts +70 -4
- package/extensions/lsp/keep-alive.ts +1 -1
- package/extensions/lsp/trust.ts +119 -0
- package/extensions/lsp/watcher.ts +1 -1
- package/extensions/mcp/announce.ts +87 -5
- package/extensions/mcp/client.ts +161 -48
- package/extensions/mcp/config.ts +132 -8
- package/extensions/mcp/discovery.ts +19 -0
- package/extensions/mcp/index.ts +209 -71
- package/extensions/mcp/oauth/browser.ts +38 -17
- package/extensions/mcp/oauth/flow.ts +25 -17
- package/extensions/mcp/oauth/provider.ts +14 -8
- package/extensions/mcp/oauth/store.ts +34 -16
- package/extensions/mcp/panel/keys.ts +12 -11
- package/extensions/mcp/panel/render.ts +9 -6
- package/extensions/mcp/schema.ts +104 -5
- package/extensions/mcp/trust.ts +151 -67
- package/extensions/memory/entries.ts +23 -14
- package/extensions/memory/index.ts +134 -28
- package/extensions/memory/open-external.ts +33 -18
- package/extensions/memory/panel.ts +135 -26
- package/extensions/newer-model/index.ts +31 -0
- package/extensions/notebook/description.ts +18 -0
- package/extensions/notebook/index.ts +6 -5
- package/extensions/notebook/notebook.ts +1 -1
- package/extensions/permissions/index.ts +1300 -381
- package/extensions/permissions/matcher.ts +425 -74
- package/extensions/permissions/modes.ts +6 -4
- package/extensions/permissions/panel/host.ts +130 -0
- package/extensions/permissions/panel/keys.ts +48 -0
- package/extensions/permissions/panel/render.ts +450 -0
- package/extensions/permissions/panel/state.ts +550 -0
- package/extensions/permissions/powershell-cmdlets.ts +198 -0
- package/extensions/permissions/powershell-paths.ts +197 -0
- package/extensions/permissions/powershell-rules.ts +207 -280
- package/extensions/permissions/powershell-tree.ts +557 -0
- package/extensions/permissions/project-trust.ts +27 -11
- package/extensions/permissions/protected-paths.ts +14 -2
- package/extensions/permissions/session-grant.ts +6 -0
- package/extensions/permissions/settings.ts +166 -31
- package/extensions/permissions/subagent-gate.ts +25 -0
- package/extensions/permissions/workspace.ts +78 -0
- package/extensions/plan-mode/index.ts +156 -69
- package/extensions/plan-mode/reminder.ts +89 -21
- package/extensions/plan-mode/texts.ts +177 -0
- package/extensions/plan-mode/viewer.ts +92 -27
- package/extensions/plugins/counts.ts +3 -3
- package/extensions/plugins/index.ts +61 -46
- package/extensions/plugins/install/install.ts +16 -0
- package/extensions/plugins/marketplace/sync.ts +9 -3
- package/extensions/plugins/marketplace/types.ts +3 -1
- package/extensions/plugins/panel/keys.ts +21 -24
- package/extensions/plugins/panel/render.ts +12 -6
- package/extensions/plugins/shell-expand.ts +107 -0
- package/extensions/plugins/template.ts +32 -15
- package/extensions/powershell/child.ts +7 -0
- package/extensions/powershell/description.ts +38 -6
- package/extensions/powershell/guards.ts +1 -1
- package/extensions/powershell/index.ts +12 -7
- package/extensions/recap/index.ts +65 -60
- package/extensions/recap/prompt.ts +2 -2
- package/extensions/recap/scheduler.ts +3 -5
- package/extensions/search-tools/grep-merge.ts +39 -0
- package/extensions/search-tools/index.ts +0 -0
- package/extensions/send-now/chord.ts +62 -0
- package/extensions/send-now/index.ts +133 -0
- package/extensions/session-title/index.ts +3 -0
- package/extensions/session-title/title.ts +4 -1
- package/extensions/skill/code-review.ts +97 -0
- package/extensions/skill/index.ts +421 -101
- package/extensions/skill/invoke.ts +128 -18
- package/extensions/skill/listing.ts +133 -9
- package/extensions/skill/panel/keys.ts +15 -16
- package/extensions/skill/panel/render.ts +5 -2
- package/extensions/skill/panel/state.ts +12 -1
- package/extensions/subagents/agent-args.ts +39 -0
- package/extensions/subagents/agent-description.ts +132 -0
- package/extensions/subagents/agents.ts +116 -23
- package/extensions/subagents/announce.ts +69 -0
- package/extensions/subagents/default-model.ts +2 -3
- package/extensions/subagents/delegation-steer.ts +4 -4
- package/extensions/subagents/fork-cache.ts +76 -0
- package/extensions/subagents/guide-agent.ts +189 -0
- package/extensions/subagents/hand-back-review.ts +3 -0
- package/extensions/subagents/index.ts +1493 -824
- package/extensions/subagents/live-runs.ts +26 -6
- package/extensions/subagents/model-select.ts +38 -25
- package/extensions/subagents/outcome.ts +2 -2
- package/extensions/subagents/panel-keys.ts +73 -31
- package/extensions/subagents/panel-render.ts +120 -96
- package/extensions/subagents/panel-widget.ts +60 -89
- package/extensions/subagents/runner.ts +108 -80
- package/extensions/subagents/runs.ts +39 -5
- package/extensions/subagents/send-to-main-tool.ts +1 -1
- package/extensions/subagents/session-turns.ts +32 -8
- package/extensions/subagents/shell-panel.ts +36 -178
- package/extensions/subagents/tasks-dialog.ts +170 -0
- package/extensions/subagents/viewport-mirror.ts +95 -0
- package/extensions/subagents/worktree.ts +57 -13
- package/extensions/system-prompt/environment.ts +19 -19
- package/extensions/system-prompt/idle-turn.ts +65 -0
- package/extensions/system-prompt/index.ts +131 -47
- package/extensions/system-prompt/template.ts +24 -50
- package/extensions/system-prompt/tiers/common.ts +23 -64
- package/extensions/system-prompt/tiers/frontier.ts +32 -11
- package/extensions/system-prompt/tiers/low.ts +67 -45
- package/extensions/system-prompt/tiers/mid.ts +96 -38
- package/extensions/system-reminder/index.ts +175 -13
- package/extensions/tasks/descriptions.ts +190 -0
- package/extensions/tasks/index.ts +120 -41
- package/extensions/tasks/store.ts +83 -16
- package/extensions/tool-call-corruption/child.ts +7 -0
- package/extensions/tool-call-corruption/index.ts +237 -0
- package/extensions/tool-search/announce.ts +16 -0
- package/extensions/tool-search/index.ts +189 -41
- package/extensions/tool-style/index.ts +68 -10
- package/extensions/tool-style/write-description.ts +40 -0
- package/extensions/tool-style/write-render.ts +237 -0
- package/extensions/turn-duration/index.ts +7 -3
- package/extensions/turn-duration/line.ts +47 -4
- package/extensions/web/backends.ts +6 -9
- package/extensions/web/description.ts +50 -0
- package/extensions/web/index.ts +118 -53
- package/extensions/web/native-call.ts +15 -0
- package/extensions/web/native-results.ts +41 -0
- package/extensions/web-fetch/description.ts +42 -0
- package/extensions/web-fetch/extract.ts +53 -6
- package/extensions/web-fetch/index.ts +125 -76
- package/extensions/web-fetch/page-content.ts +19 -0
- package/extensions/web-fetch/response.ts +49 -0
- package/extensions/web-fetch/summarize.ts +9 -10
- package/extensions/workflow/agent-session.ts +105 -18
- package/extensions/workflow/args.ts +24 -0
- package/extensions/workflow/description.ts +67 -0
- package/extensions/workflow/globals.ts +17 -3
- package/extensions/workflow/index.ts +145 -162
- package/extensions/workflow/records.ts +1 -0
- package/extensions/workflow/run-manager.ts +29 -6
- package/extensions/workflow/saved-workflows.ts +3 -2
- package/extensions/workflow/types.ts +22 -1
- package/extensions/workflow/viewer.ts +54 -24
- package/extensions/workflow/widget.ts +20 -0
- package/extensions/worktree/descriptions.ts +91 -0
- package/extensions/worktree/guards.ts +316 -54
- package/extensions/worktree/index.ts +121 -39
- package/extensions/worktree/powershell-guards.ts +167 -0
- package/extensions/worktree/rewrite.ts +14 -16
- package/package.json +19 -7
- package/skills/artifact-design/SKILL.md +85 -0
- package/skills/code-review/SKILL.md +20 -20
- package/skills/code-review/cells/arguments-and-flags.md +19 -0
- package/skills/code-review/cells/high-3x5-recall-verify.md +137 -0
- package/skills/code-review/cells/high-8-inline.md +122 -0
- package/skills/code-review/cells/low-1-pass-max4.md +27 -0
- package/skills/code-review/cells/low-1-pass-min-files-4.md +28 -0
- package/skills/code-review/cells/max-5x5-verify-sweep.md +162 -0
- package/skills/code-review/cells/medium-3x5-verify.md +132 -0
- package/skills/code-review/cells/minimal-prompt.md +32 -0
- package/skills/code-review/cells/xhigh-10-inline-sweep.md +149 -0
- package/skills/code-review/cells/xhigh-5x5-verify-sweep.md +162 -0
- package/skills/fewer-permission-prompts/SKILL.md +1 -1
- package/skills/loop/SKILL.md +11 -0
- package/skills/security-review/SKILL.md +2 -5
- package/skills/simplify/SKILL.md +7 -5
- package/skills/workflow-authoring/SKILL.md +143 -0
- package/themes/onecode-light.json +1 -1
- package/themes/onecode.json +1 -1
- package/types/app.d.ts +58 -0
- package/types/community-packages.d.ts +26 -1
- package/extensions/auto-mode/recoverability.ts +0 -229
- package/extensions/lib/capability-index.ts +0 -466
- package/extensions/lib/model-facts.generated.json +0 -2675
- package/extensions/lib/model-facts.ts +0 -162
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The local artifact store (pure fs, no pi imports): self-contained HTML pages
|
|
3
|
+
* the model publishes, kept under `~/.onecode/artifacts/` and viewed in the
|
|
4
|
+
* user's own browser through `file:` URLs. One Code's counterpart of Claude
|
|
5
|
+
* Code's hosted Artifact tool (`working-docs/decisions/tools.md`, "Artifacts").
|
|
6
|
+
*
|
|
7
|
+
* artifacts/index.html the gallery (gallery.ts), rewritten on every change
|
|
8
|
+
* artifacts/<id>/index.html the current version
|
|
9
|
+
* artifacts/<id>/view.html the page under a viewer bar (viewer.ts), what the browser opens
|
|
10
|
+
* artifacts/<id>/meta.json title, description, source path, project, version
|
|
11
|
+
* artifacts/<id>/download.js the current page as a script payload (see gallery.ts)
|
|
12
|
+
* artifacts/<id>/versions/v<N>.html every earlier version, never rewritten
|
|
13
|
+
* artifacts/<id>/versions/v<N>.js its download payload
|
|
14
|
+
*
|
|
15
|
+
* The meta files are the only source of truth; the gallery is regenerated from
|
|
16
|
+
* them, so two sessions publishing at once cannot leave it naming a missing
|
|
17
|
+
* page for longer than the next write. A publish stages every file before it
|
|
18
|
+
* replaces any (commitFiles), and an update holds the artifact's `.lock`, so
|
|
19
|
+
* a failed write or a concurrent publish never loses a version.
|
|
20
|
+
*
|
|
21
|
+
* Publishing the same source file again updates that artifact in place, as
|
|
22
|
+
* Claude Code's republish-by-path does; an artifact from an earlier session is
|
|
23
|
+
* updated by passing its id.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { randomUUID } from "node:crypto";
|
|
27
|
+
import { chmodSync, copyFileSync, existsSync, constants as fsConstants, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
28
|
+
import { uptime } from "node:os";
|
|
29
|
+
import { basename, extname, join, posix, resolve } from "node:path";
|
|
30
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
31
|
+
import { readJsonFile, writeTextAtomic } from "../lib/atomic-write.ts";
|
|
32
|
+
import { comparablePath, isPathAtOrUnder, oneCodeStateDir, tildify } from "../lib/paths.ts";
|
|
33
|
+
import { sanitizeDisplayText } from "../lib/terminal-text.ts";
|
|
34
|
+
import { renderDownloadPayload, renderGallery } from "./gallery.ts";
|
|
35
|
+
import { renderViewer } from "./viewer.ts";
|
|
36
|
+
|
|
37
|
+
export interface ArtifactMeta {
|
|
38
|
+
id: string;
|
|
39
|
+
title: string;
|
|
40
|
+
description?: string;
|
|
41
|
+
/** The absolute path the page was last published from. */
|
|
42
|
+
sourcePath: string;
|
|
43
|
+
/** The project (git root, else cwd) of the session that first published it. */
|
|
44
|
+
project?: string;
|
|
45
|
+
createdAt: string;
|
|
46
|
+
updatedAt: string;
|
|
47
|
+
/** The current version's number; versions/v1 … v<version - 1> hold the earlier ones. */
|
|
48
|
+
version: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Claude Code's page size limit for a published artifact. */
|
|
52
|
+
export const MAX_ARTIFACT_BYTES = 16 * 1024 * 1024;
|
|
53
|
+
|
|
54
|
+
const ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
55
|
+
const HTML_EXTENSIONS = new Set([".html", ".htm"]);
|
|
56
|
+
|
|
57
|
+
/** `~/.onecode/artifacts`, honouring `ONECODE_STATE_DIR`. */
|
|
58
|
+
export function artifactsRoot(env: Record<string, string | undefined> = process.env, home?: string): string {
|
|
59
|
+
return join(oneCodeStateDir(env, home), "artifacts");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isArtifactId(id: string): boolean {
|
|
63
|
+
return ID_PATTERN.test(id);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function pagePath(root: string, id: string): string {
|
|
67
|
+
return join(root, id, "index.html");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The page with the viewer bar over it, what the browser opens. */
|
|
71
|
+
export function viewerPath(root: string, id: string): string {
|
|
72
|
+
return join(root, id, "view.html");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function versionPath(root: string, id: string, version: number): string {
|
|
76
|
+
return join(root, id, "versions", `v${version}.html`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function galleryPath(root: string): string {
|
|
80
|
+
return join(root, "index.html");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function fileUrl(path: string): string {
|
|
84
|
+
return pathToFileURL(path).href;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Create the store private to this user (0700), tightening one created
|
|
89
|
+
* looser, so other users on the machine cannot read the pages, which carry
|
|
90
|
+
* project code and data. Windows keeps the profile's own ACLs.
|
|
91
|
+
*/
|
|
92
|
+
function ensureStore(root: string): void {
|
|
93
|
+
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
94
|
+
if (process.platform === "win32") return;
|
|
95
|
+
if (statSync(root).mode & 0o077) chmodSync(root, 0o700);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A lowercase, hyphenated slug of `text`, at most 40 characters; "page" when nothing survives. */
|
|
99
|
+
export function slugify(text: string): string {
|
|
100
|
+
const slug = text
|
|
101
|
+
.normalize("NFKD")
|
|
102
|
+
.toLowerCase()
|
|
103
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
104
|
+
.replace(/^-+|-+$/g, "")
|
|
105
|
+
.slice(0, 40)
|
|
106
|
+
.replace(/-+$/, "");
|
|
107
|
+
return slug || "page";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const ENTITIES: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " };
|
|
111
|
+
|
|
112
|
+
/** The page's `<title>` text, entity-decoded and whitespace-collapsed, or undefined. */
|
|
113
|
+
export function pageTitle(html: string): string | undefined {
|
|
114
|
+
const match = /<title\b[^>]*>([\s\S]*?)<\/title\s*>/i.exec(html);
|
|
115
|
+
if (!match) return undefined;
|
|
116
|
+
const text = match[1]
|
|
117
|
+
.replace(/&(#x[0-9a-f]+|#[0-9]+|[a-z]+);/gi, (whole, name: string) => {
|
|
118
|
+
if (name[0] === "#") {
|
|
119
|
+
const code = name[1] === "x" || name[1] === "X" ? Number.parseInt(name.slice(2), 16) : Number.parseInt(name.slice(1), 10);
|
|
120
|
+
return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : whole;
|
|
121
|
+
}
|
|
122
|
+
return ENTITIES[name.toLowerCase()] ?? whole;
|
|
123
|
+
})
|
|
124
|
+
.replace(/\s+/g, " ")
|
|
125
|
+
.trim();
|
|
126
|
+
return text || undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function readMeta(root: string, id: string): ArtifactMeta | undefined {
|
|
130
|
+
const meta = readJsonFile<ArtifactMeta>(join(root, id, "meta.json"));
|
|
131
|
+
if (!meta || typeof meta !== "object" || meta.id !== id || !Number.isInteger(meta.version)) return undefined;
|
|
132
|
+
if ([meta.title, meta.sourcePath, meta.createdAt, meta.updatedAt].some((field) => typeof field !== "string")) return undefined;
|
|
133
|
+
return meta;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** One artifact's meta, or undefined when the id is malformed or unknown. */
|
|
137
|
+
export function getArtifact(root: string, id: string): ArtifactMeta | undefined {
|
|
138
|
+
return isArtifactId(id) ? readMeta(root, id) : undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Every artifact with a readable meta file, most recently updated first. */
|
|
142
|
+
export function listArtifacts(root: string): ArtifactMeta[] {
|
|
143
|
+
let names: string[];
|
|
144
|
+
try {
|
|
145
|
+
names = readdirSync(root);
|
|
146
|
+
} catch {
|
|
147
|
+
return [];
|
|
148
|
+
}
|
|
149
|
+
return names
|
|
150
|
+
.filter(isArtifactId)
|
|
151
|
+
.map((id) => readMeta(root, id))
|
|
152
|
+
.filter((meta): meta is ArtifactMeta => meta !== undefined)
|
|
153
|
+
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || a.id.localeCompare(b.id));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The artifact id a reference names: a bare id, or a `file:` URL or path to
|
|
158
|
+
* anything inside that artifact's folder (what `publish` returned). Undefined
|
|
159
|
+
* when it names no stored artifact.
|
|
160
|
+
*/
|
|
161
|
+
export function resolveArtifactRef(root: string, ref: string): string | undefined {
|
|
162
|
+
const trimmed = ref.trim();
|
|
163
|
+
if (getArtifact(root, trimmed)) return trimmed;
|
|
164
|
+
let path: string;
|
|
165
|
+
try {
|
|
166
|
+
path = trimmed.startsWith("file:") ? fileURLToPath(trimmed) : resolve(trimmed);
|
|
167
|
+
} catch {
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
if (!isInsideStore(root, path)) return undefined;
|
|
171
|
+
// The first segment below the root, on comparablePath forms (ids are lowercase).
|
|
172
|
+
const id = posix.relative(comparablePath(root), comparablePath(path)).split("/")[0];
|
|
173
|
+
return getArtifact(root, id) ? id : undefined;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Whether `path` is inside the store (a page there cannot be published as its own source). */
|
|
177
|
+
function isInsideStore(root: string, path: string): boolean {
|
|
178
|
+
return isPathAtOrUnder(path, root);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface PublishInput {
|
|
182
|
+
/** Absolute path of the HTML file being published. */
|
|
183
|
+
sourcePath: string;
|
|
184
|
+
html: string;
|
|
185
|
+
/** Fallback title for a page with no `<title>`. */
|
|
186
|
+
title?: string;
|
|
187
|
+
description?: string;
|
|
188
|
+
project?: string;
|
|
189
|
+
/** Update this artifact instead of the one published from `sourcePath`. */
|
|
190
|
+
id?: string;
|
|
191
|
+
now: Date;
|
|
192
|
+
/** A short random suffix that keeps two same-titled artifacts apart. */
|
|
193
|
+
suffix: () => string;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export interface PublishResult {
|
|
197
|
+
meta: ArtifactMeta;
|
|
198
|
+
created: boolean;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Check a page before it is stored: the reason it cannot be published, or
|
|
203
|
+
* undefined. Every reason names the fix.
|
|
204
|
+
*/
|
|
205
|
+
export function publishProblem(root: string, sourcePath: string, bytes: number): string | undefined {
|
|
206
|
+
if (!HTML_EXTENSIONS.has(extname(sourcePath).toLowerCase())) {
|
|
207
|
+
return `${sourcePath} is not an .html file. Write the page as a single self-contained .html file (inline CSS and JS) and publish that.`;
|
|
208
|
+
}
|
|
209
|
+
if (isInsideStore(root, sourcePath)) {
|
|
210
|
+
return `${sourcePath} is inside the artifact store. Edit a copy in your scratchpad or the project and publish that copy, passing the artifact's id to update it in place.`;
|
|
211
|
+
}
|
|
212
|
+
if (bytes === 0) return `${sourcePath} is empty. Write the page first, then publish it.`;
|
|
213
|
+
if (bytes > MAX_ARTIFACT_BYTES) {
|
|
214
|
+
return `${sourcePath} is ${(bytes / 1024 / 1024).toFixed(1)} MB; an artifact is at most 16 MB. Move large data or media out of the page, or shrink it.`;
|
|
215
|
+
}
|
|
216
|
+
return undefined;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Store a page: a new artifact, or a new version of an existing one. */
|
|
220
|
+
export function publishArtifact(root: string, input: PublishInput): PublishResult {
|
|
221
|
+
const sourcePath = resolve(input.sourcePath);
|
|
222
|
+
ensureStore(root);
|
|
223
|
+
let found: ArtifactMeta | undefined;
|
|
224
|
+
if (input.id !== undefined) {
|
|
225
|
+
found = getArtifact(root, input.id);
|
|
226
|
+
if (!found) throw new Error(`No artifact has the id "${input.id}". Run the artifact tool with action "list" to see the ids.`);
|
|
227
|
+
} else {
|
|
228
|
+
// The lookup and a creation are one step under a store-wide lock, so two
|
|
229
|
+
// first publishes of one file cannot both miss and make two artifacts.
|
|
230
|
+
const key = comparablePath(sourcePath);
|
|
231
|
+
const outcome = withLock(join(root, ".create-lock"), "Another session is creating an artifact. Publish again in a moment.", () => {
|
|
232
|
+
const match = listArtifacts(root).find((meta) => comparablePath(meta.sourcePath) === key);
|
|
233
|
+
return match ? { match } : { created: createArtifact(root, input, sourcePath) };
|
|
234
|
+
});
|
|
235
|
+
if (outcome.created) {
|
|
236
|
+
writeGallery(root);
|
|
237
|
+
return outcome.created;
|
|
238
|
+
}
|
|
239
|
+
found = outcome.match;
|
|
240
|
+
}
|
|
241
|
+
const id = found.id;
|
|
242
|
+
// Re-read under the lock: another session may have published a version since.
|
|
243
|
+
const result = withLock(join(root, id, ".lock"), `Another session is publishing the artifact "${id}". Publish again in a moment.`, (held) => {
|
|
244
|
+
const existing = getArtifact(root, id);
|
|
245
|
+
if (!existing) throw new Error(`The artifact "${id}" was deleted while this publish ran. Publish again to create a new one.`);
|
|
246
|
+
return updateArtifact(root, existing, input, sourcePath, held);
|
|
247
|
+
});
|
|
248
|
+
writeGallery(root);
|
|
249
|
+
return result;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** The page's `<title>`, else a non-blank `title` parameter, else the earlier title, else the file name. */
|
|
253
|
+
function titleFor(input: PublishInput, sourcePath: string, existing?: ArtifactMeta): string {
|
|
254
|
+
const title = pageTitle(input.html) ?? (input.title?.trim() || undefined) ?? existing?.title ?? basename(sourcePath, extname(sourcePath));
|
|
255
|
+
// The title reaches terminal menus: no escape sequence or control character survives.
|
|
256
|
+
return sanitizeDisplayText(title).replace(/\s+/g, " ").trim() || basename(sourcePath, extname(sourcePath));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function createArtifact(root: string, input: PublishInput, sourcePath: string): PublishResult {
|
|
260
|
+
const title = titleFor(input, sourcePath);
|
|
261
|
+
const now = input.now.toISOString();
|
|
262
|
+
let id = `${slugify(title)}-${input.suffix()}`;
|
|
263
|
+
while (existsSync(join(root, id))) id = `${slugify(title)}-${input.suffix()}`;
|
|
264
|
+
const meta: ArtifactMeta = { id, title, description: input.description?.trim() || undefined, sourcePath, project: input.project, createdAt: now, updatedAt: now, version: 1 };
|
|
265
|
+
mkdirSync(join(root, id), { recursive: true });
|
|
266
|
+
try {
|
|
267
|
+
// meta.json last: until it lands, the folder is not an artifact.
|
|
268
|
+
commitFiles([...pageFiles(root, meta, input.html), viewerFile(root, meta), metaFile(root, meta)]);
|
|
269
|
+
} catch (error) {
|
|
270
|
+
rmSync(join(root, id), { recursive: true, force: true });
|
|
271
|
+
throw error;
|
|
272
|
+
}
|
|
273
|
+
return { meta, created: true };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function updateArtifact(root: string, existing: ArtifactMeta, input: PublishInput, sourcePath: string, held: () => boolean): PublishResult {
|
|
277
|
+
const title = titleFor(input, sourcePath, existing);
|
|
278
|
+
const description = input.description?.trim() || existing.description;
|
|
279
|
+
const now = input.now.toISOString();
|
|
280
|
+
const current = pagePath(root, existing.id);
|
|
281
|
+
// A size match first, so a changed page (the usual case) is never read back.
|
|
282
|
+
if (existsSync(current) && statSync(current).size === Buffer.byteLength(input.html) && readFileSync(current, "utf-8") === input.html) {
|
|
283
|
+
// Same bytes: refresh the details without a version that differs from nothing.
|
|
284
|
+
// The payload is rewritten too: it names the download after the title.
|
|
285
|
+
const meta: ArtifactMeta = { ...existing, title, description, sourcePath, updatedAt: now };
|
|
286
|
+
const files = [metaFile(root, meta), pageFiles(root, meta, input.html)[1], viewerFile(root, meta)];
|
|
287
|
+
// The same three files as version N had them, for a rename that fails partway.
|
|
288
|
+
const before = [metaFile(root, existing), pageFiles(root, existing, input.html)[1], viewerFile(root, existing)];
|
|
289
|
+
commitFiles(files, {
|
|
290
|
+
beforeReplace: () => assertHeld(held, existing.id),
|
|
291
|
+
rollback: before.map(([target, text]) => () => writeTextAtomic(target, text)),
|
|
292
|
+
});
|
|
293
|
+
return { meta, created: false };
|
|
294
|
+
}
|
|
295
|
+
const meta: ArtifactMeta = { ...existing, title, description, sourcePath, updatedAt: now, version: existing.version + 1 };
|
|
296
|
+
const archivedPage = versionPath(root, existing.id, existing.version);
|
|
297
|
+
const livePayload = join(root, existing.id, "download.js");
|
|
298
|
+
const archivedPayload = join(root, existing.id, "versions", `v${existing.version}.js`);
|
|
299
|
+
commitFiles([metaFile(root, meta), ...pageFiles(root, meta, input.html), viewerFile(root, meta)], {
|
|
300
|
+
beforeReplace: () => {
|
|
301
|
+
// Checked before the archive is touched: a session that took the lock
|
|
302
|
+
// over while this one was paused owns version N and N+1 now.
|
|
303
|
+
assertHeld(held, existing.id);
|
|
304
|
+
if (!existsSync(current)) return;
|
|
305
|
+
mkdirSync(join(root, existing.id, "versions"), { recursive: true });
|
|
306
|
+
copyFileSync(current, archivedPage);
|
|
307
|
+
if (existsSync(livePayload)) copyFileSync(livePayload, archivedPayload);
|
|
308
|
+
assertHeld(held, existing.id);
|
|
309
|
+
},
|
|
310
|
+
// A rename failed partway: put every live file back as version N had it.
|
|
311
|
+
rollback: [
|
|
312
|
+
() => writeTextAtomic(join(root, existing.id, "meta.json"), metaFile(root, existing)[1]),
|
|
313
|
+
() => existsSync(archivedPage) && copyFileSync(archivedPage, current),
|
|
314
|
+
() => existsSync(archivedPayload) && copyFileSync(archivedPayload, livePayload),
|
|
315
|
+
() => writeTextAtomic(viewerPath(root, existing.id), viewerFile(root, existing)[1]),
|
|
316
|
+
],
|
|
317
|
+
});
|
|
318
|
+
return { meta, created: false };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function assertHeld(held: () => boolean, id: string): void {
|
|
322
|
+
if (!held()) throw new Error(`Another session took over publishing the artifact "${id}". Publish again.`);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
type StoreFile = [target: string, text: string];
|
|
326
|
+
|
|
327
|
+
function pageFiles(root: string, meta: ArtifactMeta, html: string): StoreFile[] {
|
|
328
|
+
return [
|
|
329
|
+
[pagePath(root, meta.id), html],
|
|
330
|
+
[join(root, meta.id, "download.js"), renderDownloadPayload(`${slugify(meta.title)}.html`, html)],
|
|
331
|
+
];
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function viewerFile(root: string, meta: ArtifactMeta): StoreFile {
|
|
335
|
+
return [viewerPath(root, meta.id), renderViewer(meta, join(root, meta.id))];
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function metaFile(root: string, meta: ArtifactMeta): StoreFile {
|
|
339
|
+
return [join(root, meta.id, "meta.json"), `${JSON.stringify(meta, null, 2)}\n`];
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Write every file to a temp sibling first, run `beforeReplace` (the lock
|
|
344
|
+
* check and the version archive), then rename them into place in the given
|
|
345
|
+
* order. A failure while staging or archiving removes the temps and leaves
|
|
346
|
+
* the artifact untouched. A failed rename removes the remaining temps and runs
|
|
347
|
+
* every `rollback` step, each on its own: the file whose rename failed was
|
|
348
|
+
* never replaced, and its restore failing must not stop the others.
|
|
349
|
+
*/
|
|
350
|
+
function commitFiles(files: StoreFile[], hooks: { beforeReplace?: () => void; rollback?: Array<() => unknown> } = {}): void {
|
|
351
|
+
const staged: Array<[tmp: string, target: string]> = [];
|
|
352
|
+
const discard = () => {
|
|
353
|
+
for (const [tmp] of staged) rmSync(tmp, { force: true });
|
|
354
|
+
};
|
|
355
|
+
try {
|
|
356
|
+
for (const [target, text] of files) {
|
|
357
|
+
const tmp = `${target}.next-${process.pid}`;
|
|
358
|
+
writeFileSync(tmp, text);
|
|
359
|
+
staged.push([tmp, target]);
|
|
360
|
+
}
|
|
361
|
+
hooks.beforeReplace?.();
|
|
362
|
+
} catch (error) {
|
|
363
|
+
discard();
|
|
364
|
+
throw error;
|
|
365
|
+
}
|
|
366
|
+
try {
|
|
367
|
+
for (const [tmp, target] of staged) renameSync(tmp, target);
|
|
368
|
+
} catch (error) {
|
|
369
|
+
discard();
|
|
370
|
+
for (const step of hooks.rollback ?? []) {
|
|
371
|
+
try {
|
|
372
|
+
step();
|
|
373
|
+
} catch {
|
|
374
|
+
// The rest still run; the original error is the one reported.
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
throw error;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Another session held the lock past the wait. */
|
|
382
|
+
export class LockBusyError extends Error {}
|
|
383
|
+
|
|
384
|
+
/** How long a publish waits for another session's lock. */
|
|
385
|
+
const LOCK_WAIT_MS = 5_000;
|
|
386
|
+
/** How long an ownerless lock (a crash between `mkdir` and writing the owner) is left alone. */
|
|
387
|
+
const LOCK_ORPHAN_MS = 30_000;
|
|
388
|
+
/**
|
|
389
|
+
* Past this age a lock is abandoned even if its pid is alive: a crashed
|
|
390
|
+
* holder's pid may have been reused. A publish holds its lock for
|
|
391
|
+
* milliseconds, so only a holder asleep this long could still resume.
|
|
392
|
+
*/
|
|
393
|
+
const LOCK_MAX_AGE_MS = 10 * 60_000;
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* The process id in a lock's owner token (`<pid>-<uuid>`), or undefined.
|
|
397
|
+
*/
|
|
398
|
+
function ownerPid(token: string): number | undefined {
|
|
399
|
+
const pid = Number.parseInt(token.split("-")[0] ?? "", 10);
|
|
400
|
+
return Number.isInteger(pid) && pid > 0 ? pid : undefined;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Whether a process with this id is running (EPERM: it runs as another user). */
|
|
404
|
+
function processAlive(pid: number): boolean {
|
|
405
|
+
try {
|
|
406
|
+
process.kill(pid, 0);
|
|
407
|
+
return true;
|
|
408
|
+
} catch (error) {
|
|
409
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* The owner token of the lock at `lock` ("" for none) when it was left by a
|
|
415
|
+
* holder that can no longer act on it, else undefined: it predates this boot (its pid may name an unrelated process now), its
|
|
416
|
+
* owner process has exited, or it is this process (a lock body is
|
|
417
|
+
* synchronous, so this process cannot be inside one while it asks), or it has
|
|
418
|
+
* had no owner for 30 s. A lock whose owner is alive is never taken, however
|
|
419
|
+
* old: a paused holder resumes into its commit, and no check before a rename
|
|
420
|
+
* could fence it.
|
|
421
|
+
*/
|
|
422
|
+
function abandonedToken(lock: string): string | undefined {
|
|
423
|
+
let mtime: number;
|
|
424
|
+
try {
|
|
425
|
+
mtime = statSync(lock).mtimeMs;
|
|
426
|
+
} catch {
|
|
427
|
+
return undefined; // released meanwhile
|
|
428
|
+
}
|
|
429
|
+
const token = readOwner(lock);
|
|
430
|
+
// A lock from before this boot, or older than any real publish, is
|
|
431
|
+
// abandoned whatever its pid now names.
|
|
432
|
+
if (mtime < Date.now() - uptime() * 1000 || Date.now() - mtime > LOCK_MAX_AGE_MS) return token;
|
|
433
|
+
if (token === "") return Date.now() - mtime > LOCK_ORPHAN_MS ? token : undefined;
|
|
434
|
+
const pid = ownerPid(token);
|
|
435
|
+
return pid === undefined || pid === process.pid || !processAlive(pid) ? token : undefined;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** A lock's owner token, or "" when it has none (yet). */
|
|
439
|
+
function readOwner(lock: string): string {
|
|
440
|
+
try {
|
|
441
|
+
return readFileSync(join(lock, "owner"), "utf-8");
|
|
442
|
+
} catch {
|
|
443
|
+
return "";
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Run `fn` holding the lock at `lock`: a directory (`mkdir` is atomic across
|
|
449
|
+
* processes on one machine, which the store under `~/.onecode` is) holding an
|
|
450
|
+
* `owner` file with this holder's `<pid>-<uuid>` token. An abandoned lock
|
|
451
|
+
* (`abandonedToken`) is claimed by renaming it aside, so only one taker wins,
|
|
452
|
+
* and put back if what moved turns out to be a newer lock; a
|
|
453
|
+
* live holder's lock is waited on for 5 s, then the call fails with
|
|
454
|
+
* `LockBusyError`. `fn` gets `held()`, and the lock is released only while it
|
|
455
|
+
* is still this holder's.
|
|
456
|
+
*/
|
|
457
|
+
export function withLock<T>(lock: string, busy: string, fn: (held: () => boolean) => T, waitMs = LOCK_WAIT_MS): T {
|
|
458
|
+
const token = `${process.pid}-${randomUUID()}`;
|
|
459
|
+
const owner = join(lock, "owner");
|
|
460
|
+
const deadline = Date.now() + waitMs;
|
|
461
|
+
for (;;) {
|
|
462
|
+
try {
|
|
463
|
+
mkdirSync(lock);
|
|
464
|
+
writeFileSync(owner, token);
|
|
465
|
+
break;
|
|
466
|
+
} catch (error) {
|
|
467
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
468
|
+
const judged = abandonedToken(lock);
|
|
469
|
+
if (judged !== undefined) {
|
|
470
|
+
const aside = `${lock}.stale-${token}`;
|
|
471
|
+
try {
|
|
472
|
+
renameSync(lock, aside);
|
|
473
|
+
} catch {
|
|
474
|
+
continue; // another session claimed it first; wait on theirs
|
|
475
|
+
}
|
|
476
|
+
// Between the judgement and the rename another session may have
|
|
477
|
+
// reclaimed the lock and made a new one here: if what moved is not
|
|
478
|
+
// the lock judged abandoned, put it back for its live owner.
|
|
479
|
+
// If the put-back fails (yet another lock is there now), the moved one
|
|
480
|
+
// is left aside, never removed: its owner's held() turns false and
|
|
481
|
+
// its publish aborts before touching live files.
|
|
482
|
+
if (readOwner(aside) === judged) rmSync(aside, { recursive: true, force: true });
|
|
483
|
+
else {
|
|
484
|
+
try {
|
|
485
|
+
renameSync(aside, lock);
|
|
486
|
+
} catch {
|
|
487
|
+
// left aside on purpose
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
if (Date.now() > deadline) throw new LockBusyError(busy);
|
|
493
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
const held = () => {
|
|
497
|
+
try {
|
|
498
|
+
return readFileSync(owner, "utf-8") === token;
|
|
499
|
+
} catch {
|
|
500
|
+
return false;
|
|
501
|
+
}
|
|
502
|
+
};
|
|
503
|
+
try {
|
|
504
|
+
return fn(held);
|
|
505
|
+
} finally {
|
|
506
|
+
if (held()) rmSync(lock, { recursive: true, force: true });
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Remove an artifact and every version of it, under its publish lock so a
|
|
512
|
+
* publish in flight finishes first. False when there was nothing to remove.
|
|
513
|
+
*/
|
|
514
|
+
export function deleteArtifact(root: string, id: string): boolean {
|
|
515
|
+
if (!getArtifact(root, id)) return false;
|
|
516
|
+
const removed = withLock(join(root, id, ".lock"), `Another session is publishing the artifact "${id}". Delete it again in a moment.`, () => {
|
|
517
|
+
if (!getArtifact(root, id)) return false;
|
|
518
|
+
rmSync(join(root, id), { recursive: true, force: true });
|
|
519
|
+
return true;
|
|
520
|
+
});
|
|
521
|
+
if (removed) writeGallery(root);
|
|
522
|
+
return removed;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Rewrite the gallery page from the meta files. The snapshot is taken and
|
|
527
|
+
* written under a store-wide lock, so a slower session cannot put an older
|
|
528
|
+
* snapshot over a newer one. A gallery still locked after the wait is left to
|
|
529
|
+
* that session: the gallery is derived, and the next change or `open`
|
|
530
|
+
* rewrites it, so a publish that already landed does not report failure.
|
|
531
|
+
*/
|
|
532
|
+
export function writeGallery(root: string): void {
|
|
533
|
+
ensureStore(root);
|
|
534
|
+
try {
|
|
535
|
+
withLock(join(root, ".gallery-lock"), "The artifact gallery is locked by another session.", (held) => {
|
|
536
|
+
const html = renderGallery(listArtifacts(root));
|
|
537
|
+
// A session that took the lock over while this one was paused has a newer snapshot.
|
|
538
|
+
if (held()) writeTextAtomic(galleryPath(root), html);
|
|
539
|
+
});
|
|
540
|
+
} catch (error) {
|
|
541
|
+
if (!(error instanceof LockBusyError)) throw error;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** One artifact as a line of the tool's `list` output and the non-interactive /artifacts. */
|
|
546
|
+
export function listLine(root: string, meta: ArtifactMeta, home: string): string {
|
|
547
|
+
const project = meta.project ? ` [${tildify(meta.project, home)}]` : "";
|
|
548
|
+
return `- ${meta.id}: "${meta.title}" (v${meta.version}, updated ${meta.updatedAt.slice(0, 16).replace("T", " ")} UTC)${project}\n ${fileUrl(viewerPath(root, meta.id))}`;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Copy an artifact's current version to `<home>/Downloads` (`home` itself when
|
|
553
|
+
* there is none) as `<slug>.html`, adding -2, -3 … rather than overwriting.
|
|
554
|
+
* Returns the path written.
|
|
555
|
+
*/
|
|
556
|
+
export function exportArtifact(root: string, meta: ArtifactMeta, home: string): string {
|
|
557
|
+
const downloads = join(home, "Downloads");
|
|
558
|
+
const dir = existsSync(downloads) ? downloads : home;
|
|
559
|
+
const base = slugify(meta.title);
|
|
560
|
+
// An exclusive copy, so two exports of one name cannot both take it.
|
|
561
|
+
for (let n = 1; ; n++) {
|
|
562
|
+
const target = join(dir, n === 1 ? `${base}.html` : `${base}-${n}.html`);
|
|
563
|
+
try {
|
|
564
|
+
copyFileSync(pagePath(root, meta.id), target, fsConstants.COPYFILE_EXCL);
|
|
565
|
+
return target;
|
|
566
|
+
} catch (error) {
|
|
567
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|