patchwork-os 0.2.0-alpha.33 → 0.2.0-alpha.35
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 +248 -48
- package/deploy/bootstrap-new-vps.sh +12 -12
- package/deploy/bootstrap-vps.sh +6 -3
- package/deploy/deploy-landing.sh +59 -2
- package/dist/bridge.js +35 -1
- package/dist/bridge.js.map +1 -1
- package/dist/commands/recipe.d.ts +11 -0
- package/dist/commands/recipe.js +32 -3
- package/dist/commands/recipe.js.map +1 -1
- package/dist/commands/recipeInstall.d.ts +79 -1
- package/dist/commands/recipeInstall.js +241 -13
- package/dist/commands/recipeInstall.js.map +1 -1
- package/dist/connectors/asana.d.ts +198 -0
- package/dist/connectors/asana.js +680 -0
- package/dist/connectors/asana.js.map +1 -0
- package/dist/connectors/baseConnector.d.ts +16 -0
- package/dist/connectors/baseConnector.js +107 -25
- package/dist/connectors/baseConnector.js.map +1 -1
- package/dist/connectors/discord.d.ts +150 -0
- package/dist/connectors/discord.js +544 -0
- package/dist/connectors/discord.js.map +1 -0
- package/dist/connectors/github.js +15 -7
- package/dist/connectors/github.js.map +1 -1
- package/dist/connectors/gitlab.d.ts +180 -0
- package/dist/connectors/gitlab.js +582 -0
- package/dist/connectors/gitlab.js.map +1 -0
- package/dist/connectors/gmail.js +45 -0
- package/dist/connectors/gmail.js.map +1 -1
- package/dist/connectors/googleDrive.d.ts +34 -0
- package/dist/connectors/googleDrive.js +305 -0
- package/dist/connectors/googleDrive.js.map +1 -0
- package/dist/connectors/htmlEscape.d.ts +5 -0
- package/dist/connectors/htmlEscape.js +13 -0
- package/dist/connectors/htmlEscape.js.map +1 -0
- package/dist/connectors/linear.js +26 -6
- package/dist/connectors/linear.js.map +1 -1
- package/dist/connectors/mcpOAuth.d.ts +2 -0
- package/dist/connectors/mcpOAuth.js +8 -4
- package/dist/connectors/mcpOAuth.js.map +1 -1
- package/dist/connectors/pagerduty.d.ts +160 -0
- package/dist/connectors/pagerduty.js +464 -0
- package/dist/connectors/pagerduty.js.map +1 -0
- package/dist/connectors/sentry.js +3 -2
- package/dist/connectors/sentry.js.map +1 -1
- package/dist/connectors/slack.d.ts +1 -1
- package/dist/connectors/slack.js +7 -4
- package/dist/connectors/slack.js.map +1 -1
- package/dist/featureFlags.d.ts +17 -11
- package/dist/featureFlags.js +52 -47
- package/dist/featureFlags.js.map +1 -1
- package/dist/index.js +262 -129
- package/dist/index.js.map +1 -1
- package/dist/oauth.js +3 -2
- package/dist/oauth.js.map +1 -1
- package/dist/recipeOrchestration.d.ts +7 -0
- package/dist/recipeOrchestration.js +154 -28
- package/dist/recipeOrchestration.js.map +1 -1
- package/dist/recipes/agentExecutor.d.ts +1 -0
- package/dist/recipes/agentExecutor.js +7 -0
- package/dist/recipes/agentExecutor.js.map +1 -1
- package/dist/recipes/captureForRunlog.d.ts +27 -0
- package/dist/recipes/captureForRunlog.js +128 -0
- package/dist/recipes/captureForRunlog.js.map +1 -0
- package/dist/recipes/chainedRunner.d.ts +39 -3
- package/dist/recipes/chainedRunner.js +183 -28
- package/dist/recipes/chainedRunner.js.map +1 -1
- package/dist/recipes/detectSilentFail.d.ts +34 -0
- package/dist/recipes/detectSilentFail.js +105 -0
- package/dist/recipes/detectSilentFail.js.map +1 -0
- package/dist/recipes/legacyRecipeCompat.d.ts +8 -0
- package/dist/recipes/legacyRecipeCompat.js +20 -1
- package/dist/recipes/legacyRecipeCompat.js.map +1 -1
- package/dist/recipes/manifest.js +21 -6
- package/dist/recipes/manifest.js.map +1 -1
- package/dist/recipes/migrations/index.d.ts +24 -0
- package/dist/recipes/migrations/index.js +55 -0
- package/dist/recipes/migrations/index.js.map +1 -0
- package/dist/recipes/migrations/types.d.ts +28 -0
- package/dist/recipes/migrations/types.js +2 -0
- package/dist/recipes/migrations/types.js.map +1 -0
- package/dist/recipes/migrations/v1.d.ts +11 -0
- package/dist/recipes/migrations/v1.js +18 -0
- package/dist/recipes/migrations/v1.js.map +1 -0
- package/dist/recipes/replayRun.d.ts +62 -0
- package/dist/recipes/replayRun.js +97 -0
- package/dist/recipes/replayRun.js.map +1 -0
- package/dist/recipes/scheduler.js +102 -11
- package/dist/recipes/scheduler.js.map +1 -1
- package/dist/recipes/schemaGenerator.js +3 -3
- package/dist/recipes/schemaGenerator.js.map +1 -1
- package/dist/recipes/templateEngine.js +8 -1
- package/dist/recipes/templateEngine.js.map +1 -1
- package/dist/recipes/toolRegistry.d.ts +5 -0
- package/dist/recipes/toolRegistry.js +9 -0
- package/dist/recipes/toolRegistry.js.map +1 -1
- package/dist/recipes/tools/asana.d.ts +16 -0
- package/dist/recipes/tools/asana.js +524 -0
- package/dist/recipes/tools/asana.js.map +1 -0
- package/dist/recipes/tools/discord.d.ts +18 -0
- package/dist/recipes/tools/discord.js +254 -0
- package/dist/recipes/tools/discord.js.map +1 -0
- package/dist/recipes/tools/github.js +29 -4
- package/dist/recipes/tools/github.js.map +1 -1
- package/dist/recipes/tools/gitlab.d.ts +11 -0
- package/dist/recipes/tools/gitlab.js +285 -0
- package/dist/recipes/tools/gitlab.js.map +1 -0
- package/dist/recipes/tools/gmail.d.ts +1 -1
- package/dist/recipes/tools/gmail.js +230 -6
- package/dist/recipes/tools/gmail.js.map +1 -1
- package/dist/recipes/tools/googleDrive.d.ts +1 -0
- package/dist/recipes/tools/googleDrive.js +55 -0
- package/dist/recipes/tools/googleDrive.js.map +1 -0
- package/dist/recipes/tools/index.d.ts +6 -0
- package/dist/recipes/tools/index.js +6 -0
- package/dist/recipes/tools/index.js.map +1 -1
- package/dist/recipes/tools/linear.d.ts +2 -1
- package/dist/recipes/tools/linear.js +222 -1
- package/dist/recipes/tools/linear.js.map +1 -1
- package/dist/recipes/tools/meetingNotes.d.ts +21 -0
- package/dist/recipes/tools/meetingNotes.js +701 -0
- package/dist/recipes/tools/meetingNotes.js.map +1 -0
- package/dist/recipes/tools/pagerduty.d.ts +15 -0
- package/dist/recipes/tools/pagerduty.js +451 -0
- package/dist/recipes/tools/pagerduty.js.map +1 -0
- package/dist/recipes/tools/slack.js +8 -2
- package/dist/recipes/tools/slack.js.map +1 -1
- package/dist/recipes/validation.js +54 -15
- package/dist/recipes/validation.js.map +1 -1
- package/dist/recipes/yamlRunner.d.ts +23 -2
- package/dist/recipes/yamlRunner.js +265 -60
- package/dist/recipes/yamlRunner.js.map +1 -1
- package/dist/recipesHttp.d.ts +60 -0
- package/dist/recipesHttp.js +418 -3
- package/dist/recipesHttp.js.map +1 -1
- package/dist/runLog.d.ts +64 -2
- package/dist/runLog.js +116 -2
- package/dist/runLog.js.map +1 -1
- package/dist/server.d.ts +21 -0
- package/dist/server.js +387 -8
- package/dist/server.js.map +1 -1
- package/dist/streamableHttp.d.ts +31 -1
- package/dist/streamableHttp.js +20 -2
- package/dist/streamableHttp.js.map +1 -1
- package/dist/tools/activityLog.d.ts +2 -0
- package/dist/tools/addLinearComment.d.ts +1 -0
- package/dist/tools/batchLsp.d.ts +3 -0
- package/dist/tools/bridgeDoctor.d.ts +1 -0
- package/dist/tools/bridgeStatus.d.ts +1 -0
- package/dist/tools/cancelClaudeTask.d.ts +1 -0
- package/dist/tools/checkDocumentDirty.d.ts +1 -0
- package/dist/tools/clipboard.d.ts +2 -0
- package/dist/tools/closeTabs.d.ts +2 -0
- package/dist/tools/codeLens.d.ts +1 -0
- package/dist/tools/contextBundle.d.ts +1 -0
- package/dist/tools/createIssueFromAIComment.d.ts +1 -0
- package/dist/tools/createLinearIssue.d.ts +1 -0
- package/dist/tools/ctxGetTaskContext.d.ts +1 -0
- package/dist/tools/ctxQueryTraces.d.ts +1 -0
- package/dist/tools/ctxSaveTrace.d.ts +1 -0
- package/dist/tools/debug.d.ts +4 -0
- package/dist/tools/decorations.d.ts +2 -0
- package/dist/tools/documentLinks.d.ts +1 -0
- package/dist/tools/editText.d.ts +1 -0
- package/dist/tools/enrichCommit.d.ts +1 -0
- package/dist/tools/enrichStackTrace.d.ts +1 -0
- package/dist/tools/explainDiagnostic.d.ts +1 -0
- package/dist/tools/explainSymbol.d.ts +1 -0
- package/dist/tools/fetchCalendarEvents.d.ts +1 -0
- package/dist/tools/fetchGithubIssue.d.ts +1 -0
- package/dist/tools/fetchGithubPR.d.ts +1 -0
- package/dist/tools/fetchLinearIssue.d.ts +1 -0
- package/dist/tools/fetchSentryIssue.d.ts +1 -0
- package/dist/tools/fetchSlackProfile.d.ts +1 -0
- package/dist/tools/fileOperations.d.ts +3 -0
- package/dist/tools/fileWatcher.d.ts +2 -0
- package/dist/tools/findFiles.d.ts +1 -0
- package/dist/tools/findRelatedTests.d.ts +1 -0
- package/dist/tools/fixAllLintErrors.d.ts +1 -0
- package/dist/tools/foldingRanges.d.ts +1 -0
- package/dist/tools/formatDocument.d.ts +1 -0
- package/dist/tools/generateTests.d.ts +1 -0
- package/dist/tools/getAIComments.d.ts +1 -0
- package/dist/tools/getAnalyticsReport.d.ts +1 -0
- package/dist/tools/getArchitectureContext.d.ts +1 -0
- package/dist/tools/getBufferContent.d.ts +1 -0
- package/dist/tools/getChangeImpact.d.ts +1 -0
- package/dist/tools/getClaudeTaskStatus.d.ts +1 -0
- package/dist/tools/getCodeCoverage.d.ts +1 -0
- package/dist/tools/getCommitsForIssue.d.ts +1 -0
- package/dist/tools/getConnectorStatus.d.ts +1 -0
- package/dist/tools/getCurrentSelection.d.ts +2 -0
- package/dist/tools/getDebugState.d.ts +1 -0
- package/dist/tools/getDependencyTree.d.ts +1 -0
- package/dist/tools/getDiagnostics.d.ts +1 -0
- package/dist/tools/getDiffFromHandoff.d.ts +1 -0
- package/dist/tools/getDocumentSymbols.d.ts +1 -0
- package/dist/tools/getFileTree.d.ts +1 -0
- package/dist/tools/getGitDiff.d.ts +1 -0
- package/dist/tools/getGitHotspots.d.ts +1 -0
- package/dist/tools/getGitLog.d.ts +1 -0
- package/dist/tools/getGitStatus.d.ts +1 -0
- package/dist/tools/getImportTree.d.ts +1 -0
- package/dist/tools/getImportedSignatures.d.ts +1 -0
- package/dist/tools/getOpenEditors.d.ts +1 -0
- package/dist/tools/getPRTemplate.d.ts +1 -0
- package/dist/tools/getProjectContext.d.ts +1 -0
- package/dist/tools/getProjectInfo.d.ts +1 -0
- package/dist/tools/getSecurityAdvisories.d.ts +1 -0
- package/dist/tools/getSessionUsage.d.ts +1 -0
- package/dist/tools/getSymbolHistory.d.ts +1 -0
- package/dist/tools/getToolCapabilities.d.ts +1 -0
- package/dist/tools/getTypeSignature.d.ts +1 -0
- package/dist/tools/getWorkspaceFolders.d.ts +1 -0
- package/dist/tools/getWorkspaceSettings.d.ts +1 -0
- package/dist/tools/gitHistory.d.ts +2 -0
- package/dist/tools/gitWrite.d.ts +11 -0
- package/dist/tools/github/actions.d.ts +2 -0
- package/dist/tools/github/composite.d.ts +3 -0
- package/dist/tools/github/issues.d.ts +4 -0
- package/dist/tools/github/pr.d.ts +7 -0
- package/dist/tools/handoffNote.d.ts +2 -0
- package/dist/tools/hoverAtCursor.d.ts +1 -0
- package/dist/tools/httpClient.d.ts +2 -0
- package/dist/tools/inlayHints.d.ts +1 -0
- package/dist/tools/launchQuickTask.d.ts +1 -0
- package/dist/tools/listClaudeTasks.d.ts +1 -0
- package/dist/tools/listTerminals.d.ts +1 -0
- package/dist/tools/lsp.d.ts +14 -0
- package/dist/tools/navigateToSymbolByName.d.ts +1 -0
- package/dist/tools/openDiff.d.ts +1 -0
- package/dist/tools/openFile.d.ts +1 -0
- package/dist/tools/openInBrowser.d.ts +1 -0
- package/dist/tools/organizeImports.d.ts +1 -0
- package/dist/tools/performanceReport.d.ts +1 -0
- package/dist/tools/planPersistence.d.ts +5 -0
- package/dist/tools/previewEdit.d.ts +1 -0
- package/dist/tools/refactorAnalyze.d.ts +1 -0
- package/dist/tools/refactorPreview.d.ts +1 -0
- package/dist/tools/replaceBlock.d.ts +1 -0
- package/dist/tools/resumeClaudeTask.d.ts +1 -0
- package/dist/tools/runClaudeTask.d.ts +1 -0
- package/dist/tools/runCommand.d.ts +1 -0
- package/dist/tools/runTests.d.ts +1 -0
- package/dist/tools/saveDocument.d.ts +1 -0
- package/dist/tools/screenshotAndAnnotate.d.ts +1 -0
- package/dist/tools/searchAndReplace.d.ts +1 -0
- package/dist/tools/searchTools.d.ts +1 -0
- package/dist/tools/searchWorkspace.d.ts +1 -0
- package/dist/tools/selectionRanges.d.ts +1 -0
- package/dist/tools/semanticTokens.d.ts +1 -0
- package/dist/tools/setActiveWorkspaceFolder.d.ts +1 -0
- package/dist/tools/signatureHelp.d.ts +1 -0
- package/dist/tools/slackListChannels.d.ts +1 -0
- package/dist/tools/slackPostMessage.d.ts +1 -0
- package/dist/tools/slackPostMessage.js +1 -1
- package/dist/tools/slackPostMessage.js.map +1 -1
- package/dist/tools/terminal.d.ts +6 -0
- package/dist/tools/testTraceToSource.d.ts +1 -0
- package/dist/tools/transaction.d.ts +4 -0
- package/dist/tools/typeHierarchy.d.ts +1 -0
- package/dist/tools/updateLinearIssue.d.ts +1 -0
- package/dist/tools/utils.d.ts +2 -0
- package/dist/tools/utils.js.map +1 -1
- package/dist/tools/vscodeCommands.d.ts +2 -0
- package/dist/tools/vscodeTasks.d.ts +2 -0
- package/dist/tools/workspaceSettings.d.ts +1 -0
- package/package.json +20 -4
- package/templates/recipes/project-health-check.yaml +1 -1
- package/dist/schemas/dry-run-plan.v1.json +0 -139
- package/dist/schemas/recipe.v1.json +0 -684
package/dist/recipesHttp.d.ts
CHANGED
|
@@ -1,3 +1,35 @@
|
|
|
1
|
+
import { loadConfig } from "./patchworkConfig.js";
|
|
2
|
+
/**
|
|
3
|
+
* Returns true unless `filePath` lives inside an install dir whose
|
|
4
|
+
* `.disabled` marker is present. Top-level legacy recipes (direct children
|
|
5
|
+
* of `recipesDir`) are always considered enabled — there's no install dir
|
|
6
|
+
* to put a marker in. Used by every trigger surface (webhook, manual fire,
|
|
7
|
+
* automation) so the marker means the same thing everywhere.
|
|
8
|
+
*/
|
|
9
|
+
export declare function isRecipeFileEnabled(filePath: string, recipesDir: string): boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Unified enable/disable for install-dir AND legacy top-level recipes.
|
|
12
|
+
*
|
|
13
|
+
* Routing:
|
|
14
|
+
* 1. Try to find an install dir whose entrypoint declares this `name`.
|
|
15
|
+
* If found, write/remove the `.disabled` marker on that dir. This
|
|
16
|
+
* matches CLI `recipe enable/disable` and the trigger-side
|
|
17
|
+
* enforcement landed in PRs #43 / #49.
|
|
18
|
+
* 2. Otherwise the recipe is a top-level legacy file — fall back to
|
|
19
|
+
* the legacy `cfg.recipes.disabled` config-file array, which the
|
|
20
|
+
* scheduler already honors as a parallel mechanism (it checks both).
|
|
21
|
+
*
|
|
22
|
+
* Replaces the old dashboard-only `setRecipeEnabledFn` that wrote ONLY to
|
|
23
|
+
* the legacy config — which silently did nothing for install-dir recipes.
|
|
24
|
+
*/
|
|
25
|
+
export declare function setRecipeEnabled(name: string, enabled: boolean, options?: {
|
|
26
|
+
recipesDir?: string;
|
|
27
|
+
loadConfigFn?: typeof loadConfig;
|
|
28
|
+
saveConfigFn?: (cfg: unknown) => void;
|
|
29
|
+
}): {
|
|
30
|
+
ok: boolean;
|
|
31
|
+
error?: string;
|
|
32
|
+
};
|
|
1
33
|
/**
|
|
2
34
|
* Patchwork recipes HTTP surface — reads installed recipes from disk so the
|
|
3
35
|
* dashboard Recipes page can list what's available. The bridge does not yet
|
|
@@ -39,10 +71,31 @@ export declare function saveRecipeContent(recipesDir: string, name: string, cont
|
|
|
39
71
|
path?: string;
|
|
40
72
|
error?: string;
|
|
41
73
|
};
|
|
74
|
+
/**
|
|
75
|
+
* Deletes a recipe file (yaml/yml or json) plus any sidecar permissions file.
|
|
76
|
+
* Returns ok=false with a 404-style error when the recipe cannot be located.
|
|
77
|
+
*/
|
|
78
|
+
export declare function deleteRecipeContent(recipesDir: string, name: string): {
|
|
79
|
+
ok: boolean;
|
|
80
|
+
path?: string;
|
|
81
|
+
error?: string;
|
|
82
|
+
};
|
|
83
|
+
/**
|
|
84
|
+
* Lints raw YAML/JSON recipe content without writing to disk. Used by the
|
|
85
|
+
* dashboard edit UI to surface validateRecipeDefinition warnings live, in
|
|
86
|
+
* addition to the warnings returned by saveRecipeContent on save.
|
|
87
|
+
*/
|
|
88
|
+
export declare function lintRecipeContent(content: string): {
|
|
89
|
+
ok: boolean;
|
|
90
|
+
errors: string[];
|
|
91
|
+
warnings: string[];
|
|
92
|
+
};
|
|
42
93
|
export interface RecipeSummary {
|
|
43
94
|
name: string;
|
|
44
95
|
description?: string;
|
|
45
96
|
trigger?: string;
|
|
97
|
+
/** For webhook triggers, the configured path (e.g. "/github-pr"). */
|
|
98
|
+
webhookPath?: string;
|
|
46
99
|
stepCount: number;
|
|
47
100
|
path: string;
|
|
48
101
|
installedAt: number;
|
|
@@ -55,6 +108,13 @@ export interface RecipeSummary {
|
|
|
55
108
|
required?: boolean;
|
|
56
109
|
default?: string;
|
|
57
110
|
}>;
|
|
111
|
+
/** Lint summary so the dashboard list can flag invalid recipes without N+1 fetches. */
|
|
112
|
+
lint?: {
|
|
113
|
+
ok: boolean;
|
|
114
|
+
errorCount: number;
|
|
115
|
+
warningCount: number;
|
|
116
|
+
firstError?: string;
|
|
117
|
+
};
|
|
58
118
|
}
|
|
59
119
|
export interface ListRecipesResult {
|
|
60
120
|
recipesDir: string;
|
package/dist/recipesHttp.js
CHANGED
|
@@ -1,8 +1,190 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { parse as parseYaml } from "yaml";
|
|
4
5
|
import { loadConfig } from "./patchworkConfig.js";
|
|
5
6
|
import { validateRecipeDefinition } from "./recipes/validation.js";
|
|
7
|
+
/**
|
|
8
|
+
* Per-recipe disabled marker — must match the constant in
|
|
9
|
+
* `src/commands/recipeInstall.ts` and `src/recipes/scheduler.ts` (kept inline
|
|
10
|
+
* here to avoid a circular import via commands → recipesHttp → commands).
|
|
11
|
+
*
|
|
12
|
+
* Absence on a recipe's install dir = enabled (legacy default).
|
|
13
|
+
* Presence = disabled — `runRecipeInstall` writes one on every fresh install.
|
|
14
|
+
*/
|
|
15
|
+
const DISABLED_MARKER = ".disabled";
|
|
16
|
+
/**
|
|
17
|
+
* Returns true unless `filePath` lives inside an install dir whose
|
|
18
|
+
* `.disabled` marker is present. Top-level legacy recipes (direct children
|
|
19
|
+
* of `recipesDir`) are always considered enabled — there's no install dir
|
|
20
|
+
* to put a marker in. Used by every trigger surface (webhook, manual fire,
|
|
21
|
+
* automation) so the marker means the same thing everywhere.
|
|
22
|
+
*/
|
|
23
|
+
export function isRecipeFileEnabled(filePath, recipesDir) {
|
|
24
|
+
const rel = path.relative(recipesDir, filePath);
|
|
25
|
+
// Top-level file in recipesDir → no install dir → enabled by default.
|
|
26
|
+
if (rel === "" || rel.startsWith("..") || !rel.includes(path.sep)) {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
const installDirName = rel.split(path.sep)[0];
|
|
30
|
+
if (!installDirName)
|
|
31
|
+
return true;
|
|
32
|
+
const installDir = path.join(recipesDir, installDirName);
|
|
33
|
+
return !existsSync(path.join(installDir, DISABLED_MARKER));
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Iterate one level of subdirectories under `recipesDir` that look like
|
|
37
|
+
* install dirs (directory containing `recipe.json` or at least one `.yaml`).
|
|
38
|
+
* Skips dirs whose `.disabled` marker is present so callers automatically
|
|
39
|
+
* honor the marker without having to remember.
|
|
40
|
+
*
|
|
41
|
+
* Yields `{ installDir, entrypointPath }` pairs where `entrypointPath` is the
|
|
42
|
+
* file the caller should parse:
|
|
43
|
+
* - `recipe.json`'s `recipes.main` if a manifest exists
|
|
44
|
+
* - otherwise the first `*.yaml` / `*.yml` in the dir
|
|
45
|
+
*
|
|
46
|
+
* Used by webhook + manual-fire path resolvers to find recipes installed
|
|
47
|
+
* via `runRecipeInstall`.
|
|
48
|
+
*/
|
|
49
|
+
function* iterateInstallDirs(recipesDir, options = {}) {
|
|
50
|
+
const includeDisabled = options.includeDisabled === true;
|
|
51
|
+
let entries;
|
|
52
|
+
try {
|
|
53
|
+
entries = readdirSync(recipesDir);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
for (const f of entries) {
|
|
59
|
+
const fullPath = path.join(recipesDir, f);
|
|
60
|
+
let isDir = false;
|
|
61
|
+
try {
|
|
62
|
+
isDir = statSync(fullPath).isDirectory();
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (!isDir)
|
|
68
|
+
continue;
|
|
69
|
+
const enabled = !existsSync(path.join(fullPath, DISABLED_MARKER));
|
|
70
|
+
if (!enabled && !includeDisabled)
|
|
71
|
+
continue;
|
|
72
|
+
let entrypoint = null;
|
|
73
|
+
const manifestPath = path.join(fullPath, "recipe.json");
|
|
74
|
+
if (existsSync(manifestPath)) {
|
|
75
|
+
try {
|
|
76
|
+
const m = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
77
|
+
if (m.recipes?.main) {
|
|
78
|
+
const candidate = path.join(fullPath, m.recipes.main);
|
|
79
|
+
if (existsSync(candidate))
|
|
80
|
+
entrypoint = candidate;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// malformed manifest — fall through to first-yaml fallback
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (!entrypoint) {
|
|
88
|
+
try {
|
|
89
|
+
const yaml = readdirSync(fullPath).find((x) => /\.ya?ml$/i.test(x));
|
|
90
|
+
if (yaml)
|
|
91
|
+
entrypoint = path.join(fullPath, yaml);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// unreadable
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (entrypoint) {
|
|
98
|
+
yield { installDir: fullPath, entrypointPath: entrypoint, enabled };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Locate an install dir by the *recipe name* declared inside its entrypoint
|
|
104
|
+
* (not the directory name). The dashboard reports recipes by the parsed
|
|
105
|
+
* `name` field, while `runRecipeEnable` looks them up by dir name —
|
|
106
|
+
* the two are usually different (`morning-pkg` vs `morning-brief`). Includes
|
|
107
|
+
* disabled dirs so re-enabling actually finds them.
|
|
108
|
+
*/
|
|
109
|
+
function findInstallDirByRecipeName(recipesDir, name) {
|
|
110
|
+
for (const { installDir, entrypointPath } of iterateInstallDirs(recipesDir, {
|
|
111
|
+
includeDisabled: true,
|
|
112
|
+
})) {
|
|
113
|
+
try {
|
|
114
|
+
const ext = path.extname(entrypointPath).toLowerCase();
|
|
115
|
+
const raw = readFileSync(entrypointPath, "utf-8");
|
|
116
|
+
const parsed = (ext === ".json" ? JSON.parse(raw) : parseYaml(raw));
|
|
117
|
+
if (parsed.name === name)
|
|
118
|
+
return installDir;
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// skip malformed
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Unified enable/disable for install-dir AND legacy top-level recipes.
|
|
128
|
+
*
|
|
129
|
+
* Routing:
|
|
130
|
+
* 1. Try to find an install dir whose entrypoint declares this `name`.
|
|
131
|
+
* If found, write/remove the `.disabled` marker on that dir. This
|
|
132
|
+
* matches CLI `recipe enable/disable` and the trigger-side
|
|
133
|
+
* enforcement landed in PRs #43 / #49.
|
|
134
|
+
* 2. Otherwise the recipe is a top-level legacy file — fall back to
|
|
135
|
+
* the legacy `cfg.recipes.disabled` config-file array, which the
|
|
136
|
+
* scheduler already honors as a parallel mechanism (it checks both).
|
|
137
|
+
*
|
|
138
|
+
* Replaces the old dashboard-only `setRecipeEnabledFn` that wrote ONLY to
|
|
139
|
+
* the legacy config — which silently did nothing for install-dir recipes.
|
|
140
|
+
*/
|
|
141
|
+
export function setRecipeEnabled(name, enabled, options = {}) {
|
|
142
|
+
const recipesDir = options.recipesDir ?? path.join(os.homedir(), ".patchwork", "recipes");
|
|
143
|
+
try {
|
|
144
|
+
const installDir = findInstallDirByRecipeName(recipesDir, name);
|
|
145
|
+
if (installDir) {
|
|
146
|
+
const markerPath = path.join(installDir, DISABLED_MARKER);
|
|
147
|
+
if (enabled) {
|
|
148
|
+
if (existsSync(markerPath))
|
|
149
|
+
rmSync(markerPath);
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
writeFileSync(markerPath, "");
|
|
153
|
+
}
|
|
154
|
+
return { ok: true };
|
|
155
|
+
}
|
|
156
|
+
// Legacy top-level path — fall back to config-file disabled list
|
|
157
|
+
const cfg = (options.loadConfigFn ?? loadConfig)();
|
|
158
|
+
const disabled = new Set(cfg.recipes?.disabled ?? []);
|
|
159
|
+
if (enabled)
|
|
160
|
+
disabled.delete(name);
|
|
161
|
+
else
|
|
162
|
+
disabled.add(name);
|
|
163
|
+
const next = {
|
|
164
|
+
...cfg,
|
|
165
|
+
recipes: {
|
|
166
|
+
...(cfg.recipes ?? {}),
|
|
167
|
+
disabled: [...disabled],
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
if (options.saveConfigFn)
|
|
171
|
+
options.saveConfigFn(next);
|
|
172
|
+
else {
|
|
173
|
+
// Dynamic import to avoid coupling at module-load time and to keep
|
|
174
|
+
// tests able to swap the saver via options.saveConfigFn.
|
|
175
|
+
// biome-ignore lint/suspicious/noExplicitAny: dynamic require shape
|
|
176
|
+
const mod = require("./patchworkConfig.js");
|
|
177
|
+
mod.savePatchworkConfig(next);
|
|
178
|
+
}
|
|
179
|
+
return { ok: true };
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
return {
|
|
183
|
+
ok: false,
|
|
184
|
+
error: err instanceof Error ? err.message : String(err),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
}
|
|
6
188
|
function normalizeRecipeDraftTrigger(trigger) {
|
|
7
189
|
if (trigger.type === "schedule" || trigger.type === "cron") {
|
|
8
190
|
const schedule = typeof trigger.schedule === "string" && trigger.schedule.trim()
|
|
@@ -170,6 +352,22 @@ function resolveJsonRecipePathByName(recipesDir, safeName) {
|
|
|
170
352
|
catch {
|
|
171
353
|
return null;
|
|
172
354
|
}
|
|
355
|
+
// Also search install dirs from `recipeInstall`. Skips dirs with
|
|
356
|
+
// `.disabled` marker so the manual-fire / orchestrator path can't
|
|
357
|
+
// resolve a recipe the user has explicitly disabled.
|
|
358
|
+
for (const { entrypointPath } of iterateInstallDirs(recipesDir)) {
|
|
359
|
+
if (!entrypointPath.endsWith(".json"))
|
|
360
|
+
continue;
|
|
361
|
+
try {
|
|
362
|
+
const parsed = JSON.parse(readFileSync(entrypointPath, "utf-8"));
|
|
363
|
+
if (parsed.name?.toLowerCase() === safeName) {
|
|
364
|
+
return entrypointPath;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
// skip malformed
|
|
369
|
+
}
|
|
370
|
+
}
|
|
173
371
|
return null;
|
|
174
372
|
}
|
|
175
373
|
export function loadRecipeContent(recipesDir, name) {
|
|
@@ -221,9 +419,16 @@ export function saveRecipeContent(recipesDir, name, content) {
|
|
|
221
419
|
};
|
|
222
420
|
}
|
|
223
421
|
const validation = validateRecipeDefinition(parsed);
|
|
422
|
+
const warnings = validation.issues
|
|
423
|
+
.filter((issue) => issue.level === "warning")
|
|
424
|
+
.map((issue) => issue.message);
|
|
224
425
|
const validationError = validation.issues.find((issue) => issue.level === "error");
|
|
225
426
|
if (validationError) {
|
|
226
|
-
return {
|
|
427
|
+
return {
|
|
428
|
+
ok: false,
|
|
429
|
+
error: validationError.message,
|
|
430
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
431
|
+
};
|
|
227
432
|
}
|
|
228
433
|
try {
|
|
229
434
|
mkdirSync(recipesDir, { recursive: true });
|
|
@@ -234,7 +439,50 @@ export function saveRecipeContent(recipesDir, name, content) {
|
|
|
234
439
|
return { ok: false, error: "Invalid path" };
|
|
235
440
|
}
|
|
236
441
|
writeFileSync(candidate, content.endsWith("\n") ? content : `${content}\n`, "utf-8");
|
|
237
|
-
return {
|
|
442
|
+
return {
|
|
443
|
+
ok: true,
|
|
444
|
+
path: candidate,
|
|
445
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
catch (err) {
|
|
449
|
+
return {
|
|
450
|
+
ok: false,
|
|
451
|
+
error: err instanceof Error ? err.message : String(err),
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Deletes a recipe file (yaml/yml or json) plus any sidecar permissions file.
|
|
457
|
+
* Returns ok=false with a 404-style error when the recipe cannot be located.
|
|
458
|
+
*/
|
|
459
|
+
export function deleteRecipeContent(recipesDir, name) {
|
|
460
|
+
const safeName = name.toLowerCase();
|
|
461
|
+
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(safeName)) {
|
|
462
|
+
return { ok: false, error: "Invalid recipe name" };
|
|
463
|
+
}
|
|
464
|
+
const base = path.resolve(recipesDir);
|
|
465
|
+
const target = findYamlRecipePath(recipesDir, safeName) ??
|
|
466
|
+
resolveJsonRecipePathByName(recipesDir, safeName);
|
|
467
|
+
if (!target) {
|
|
468
|
+
return { ok: false, error: "Recipe not found" };
|
|
469
|
+
}
|
|
470
|
+
const resolved = path.resolve(target);
|
|
471
|
+
if (!resolved.startsWith(base + path.sep)) {
|
|
472
|
+
return { ok: false, error: "Invalid path" };
|
|
473
|
+
}
|
|
474
|
+
try {
|
|
475
|
+
rmSync(resolved, { force: true });
|
|
476
|
+
const sidecar = `${resolved}.permissions.json`;
|
|
477
|
+
if (existsSync(sidecar)) {
|
|
478
|
+
try {
|
|
479
|
+
rmSync(sidecar, { force: true });
|
|
480
|
+
}
|
|
481
|
+
catch {
|
|
482
|
+
// sidecar removal best-effort
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return { ok: true, path: resolved };
|
|
238
486
|
}
|
|
239
487
|
catch (err) {
|
|
240
488
|
return {
|
|
@@ -243,6 +491,37 @@ export function saveRecipeContent(recipesDir, name, content) {
|
|
|
243
491
|
};
|
|
244
492
|
}
|
|
245
493
|
}
|
|
494
|
+
/**
|
|
495
|
+
* Lints raw YAML/JSON recipe content without writing to disk. Used by the
|
|
496
|
+
* dashboard edit UI to surface validateRecipeDefinition warnings live, in
|
|
497
|
+
* addition to the warnings returned by saveRecipeContent on save.
|
|
498
|
+
*/
|
|
499
|
+
export function lintRecipeContent(content) {
|
|
500
|
+
if (!content.trim()) {
|
|
501
|
+
return { ok: false, errors: ["Recipe content is required"], warnings: [] };
|
|
502
|
+
}
|
|
503
|
+
let parsed;
|
|
504
|
+
try {
|
|
505
|
+
parsed = parseYaml(content);
|
|
506
|
+
}
|
|
507
|
+
catch (err) {
|
|
508
|
+
return {
|
|
509
|
+
ok: false,
|
|
510
|
+
errors: [err instanceof Error ? err.message : String(err)],
|
|
511
|
+
warnings: [],
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
const validation = validateRecipeDefinition(parsed);
|
|
515
|
+
const errors = [];
|
|
516
|
+
const warnings = [];
|
|
517
|
+
for (const issue of validation.issues) {
|
|
518
|
+
if (issue.level === "error")
|
|
519
|
+
errors.push(issue.message);
|
|
520
|
+
else
|
|
521
|
+
warnings.push(issue.message);
|
|
522
|
+
}
|
|
523
|
+
return { ok: errors.length === 0, errors, warnings };
|
|
524
|
+
}
|
|
246
525
|
export function listInstalledRecipes(recipesDir) {
|
|
247
526
|
let entries;
|
|
248
527
|
try {
|
|
@@ -287,25 +566,118 @@ export function listInstalledRecipes(recipesDir) {
|
|
|
287
566
|
}
|
|
288
567
|
const ext = isYaml ? (f.endsWith(".yml") ? ".yml" : ".yaml") : ".json";
|
|
289
568
|
const parsedName = parsed.name ?? path.basename(f, ext);
|
|
569
|
+
const lintRes = validateRecipeDefinition(parsed);
|
|
570
|
+
let errCount = 0;
|
|
571
|
+
let warnCount = 0;
|
|
572
|
+
let firstError;
|
|
573
|
+
for (const issue of lintRes.issues) {
|
|
574
|
+
if (issue.level === "error") {
|
|
575
|
+
errCount++;
|
|
576
|
+
if (!firstError)
|
|
577
|
+
firstError = issue.message;
|
|
578
|
+
}
|
|
579
|
+
else {
|
|
580
|
+
warnCount++;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
const webhookPath = parsed.trigger?.type === "webhook" &&
|
|
584
|
+
typeof parsed.trigger?.path === "string"
|
|
585
|
+
? parsed.trigger.path
|
|
586
|
+
: undefined;
|
|
290
587
|
recipes.push({
|
|
291
588
|
name: parsedName,
|
|
292
589
|
description: parsed.description,
|
|
293
590
|
trigger: parsed.trigger?.type,
|
|
591
|
+
...(webhookPath ? { webhookPath } : {}),
|
|
294
592
|
stepCount: Array.isArray(parsed.steps) ? parsed.steps.length : 0,
|
|
295
593
|
path: fullPath,
|
|
296
594
|
installedAt: stat.mtimeMs,
|
|
297
595
|
hasPermissions,
|
|
298
596
|
source,
|
|
597
|
+
// Top-level legacy recipes don't have install dirs to put a marker
|
|
598
|
+
// in, so the `enabled` field still comes from the legacy config list.
|
|
299
599
|
enabled: !disabledSet.has(parsedName),
|
|
300
600
|
...(Array.isArray(parsed.vars) && parsed.vars.length > 0
|
|
301
601
|
? { vars: parsed.vars }
|
|
302
602
|
: {}),
|
|
603
|
+
lint: {
|
|
604
|
+
ok: errCount === 0,
|
|
605
|
+
errorCount: errCount,
|
|
606
|
+
warningCount: warnCount,
|
|
607
|
+
...(firstError ? { firstError } : {}),
|
|
608
|
+
},
|
|
303
609
|
});
|
|
304
610
|
}
|
|
305
611
|
catch {
|
|
306
612
|
// skip malformed recipe file
|
|
307
613
|
}
|
|
308
614
|
}
|
|
615
|
+
// Second pass — recipes installed via `runRecipeInstall` into subdirs.
|
|
616
|
+
// `enabled` reflects the per-install `.disabled` marker; the legacy
|
|
617
|
+
// config disabled list is a top-level concern (we still apply it as a
|
|
618
|
+
// safety belt in case a name collides).
|
|
619
|
+
for (const { installDir, entrypointPath, enabled: installEnabled, } of iterateInstallDirs(recipesDir, { includeDisabled: true })) {
|
|
620
|
+
try {
|
|
621
|
+
const ext = path.extname(entrypointPath).toLowerCase();
|
|
622
|
+
const isYaml = ext === ".yaml" || ext === ".yml";
|
|
623
|
+
const isJson = ext === ".json";
|
|
624
|
+
if (!isYaml && !isJson)
|
|
625
|
+
continue;
|
|
626
|
+
const raw = readFileSync(entrypointPath, "utf-8");
|
|
627
|
+
const parsed = (isYaml ? parseYaml(raw) : JSON.parse(raw));
|
|
628
|
+
const stat = statSync(entrypointPath);
|
|
629
|
+
const parsedName = parsed.name ??
|
|
630
|
+
path.basename(entrypointPath, path.extname(entrypointPath));
|
|
631
|
+
const lintRes = validateRecipeDefinition(parsed);
|
|
632
|
+
let errCount = 0;
|
|
633
|
+
let warnCount = 0;
|
|
634
|
+
let firstError;
|
|
635
|
+
for (const issue of lintRes.issues) {
|
|
636
|
+
if (issue.level === "error") {
|
|
637
|
+
errCount++;
|
|
638
|
+
if (!firstError)
|
|
639
|
+
firstError = issue.message;
|
|
640
|
+
}
|
|
641
|
+
else {
|
|
642
|
+
warnCount++;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
const webhookPath = parsed.trigger?.type === "webhook" &&
|
|
646
|
+
typeof parsed.trigger?.path === "string"
|
|
647
|
+
? parsed.trigger.path
|
|
648
|
+
: undefined;
|
|
649
|
+
recipes.push({
|
|
650
|
+
name: parsedName,
|
|
651
|
+
description: parsed.description,
|
|
652
|
+
trigger: parsed.trigger?.type,
|
|
653
|
+
...(webhookPath ? { webhookPath } : {}),
|
|
654
|
+
stepCount: Array.isArray(parsed.steps) ? parsed.steps.length : 0,
|
|
655
|
+
path: entrypointPath,
|
|
656
|
+
installedAt: stat.mtimeMs,
|
|
657
|
+
hasPermissions: false,
|
|
658
|
+
source: "user",
|
|
659
|
+
// Disabled if EITHER the install marker is set OR the legacy config
|
|
660
|
+
// names this recipe — defence-in-depth so a stale config entry can't
|
|
661
|
+
// accidentally re-enable a recipe the user explicitly disabled, and
|
|
662
|
+
// the dashboard can't accidentally enable one disabled by an admin
|
|
663
|
+
// through the legacy file.
|
|
664
|
+
enabled: installEnabled && !disabledSet.has(parsedName),
|
|
665
|
+
...(Array.isArray(parsed.vars) && parsed.vars.length > 0
|
|
666
|
+
? { vars: parsed.vars }
|
|
667
|
+
: {}),
|
|
668
|
+
lint: {
|
|
669
|
+
ok: errCount === 0,
|
|
670
|
+
errorCount: errCount,
|
|
671
|
+
warningCount: warnCount,
|
|
672
|
+
...(firstError ? { firstError } : {}),
|
|
673
|
+
},
|
|
674
|
+
});
|
|
675
|
+
void installDir;
|
|
676
|
+
}
|
|
677
|
+
catch {
|
|
678
|
+
// skip malformed install dir
|
|
679
|
+
}
|
|
680
|
+
}
|
|
309
681
|
recipes.sort((a, b) => a.name.localeCompare(b.name));
|
|
310
682
|
return { recipesDir, recipes };
|
|
311
683
|
}
|
|
@@ -348,6 +720,22 @@ export function findYamlRecipePath(recipesDir, name) {
|
|
|
348
720
|
// skip malformed candidate
|
|
349
721
|
}
|
|
350
722
|
}
|
|
723
|
+
// Also search install dirs from `recipeInstall`. Skips dirs with
|
|
724
|
+
// `.disabled` marker so the manual-fire / orchestrator path can't
|
|
725
|
+
// resolve a recipe the user has explicitly disabled.
|
|
726
|
+
for (const { entrypointPath } of iterateInstallDirs(recipesDir)) {
|
|
727
|
+
if (!/\.ya?ml$/i.test(entrypointPath))
|
|
728
|
+
continue;
|
|
729
|
+
try {
|
|
730
|
+
const parsed = parseYaml(readFileSync(entrypointPath, "utf-8"));
|
|
731
|
+
if (parsed.name?.toLowerCase() === safeName) {
|
|
732
|
+
return entrypointPath;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
catch {
|
|
736
|
+
// skip malformed
|
|
737
|
+
}
|
|
738
|
+
}
|
|
351
739
|
return null;
|
|
352
740
|
}
|
|
353
741
|
/**
|
|
@@ -363,6 +751,7 @@ export function findWebhookRecipe(recipesDir, requestPath) {
|
|
|
363
751
|
catch {
|
|
364
752
|
return null;
|
|
365
753
|
}
|
|
754
|
+
// Pass 1 — top-level files (legacy)
|
|
366
755
|
for (const f of entries) {
|
|
367
756
|
const isYaml = f.endsWith(".yaml") || f.endsWith(".yml");
|
|
368
757
|
const isJson = f.endsWith(".json") && !f.endsWith(".permissions.json");
|
|
@@ -387,6 +776,32 @@ export function findWebhookRecipe(recipesDir, requestPath) {
|
|
|
387
776
|
// skip malformed
|
|
388
777
|
}
|
|
389
778
|
}
|
|
779
|
+
// Pass 2 — install dirs (skips dirs marked .disabled).
|
|
780
|
+
for (const { entrypointPath } of iterateInstallDirs(recipesDir)) {
|
|
781
|
+
const ext = path.extname(entrypointPath).toLowerCase();
|
|
782
|
+
const isYaml = ext === ".yaml" || ext === ".yml";
|
|
783
|
+
const isJson = ext === ".json";
|
|
784
|
+
if (!isYaml && !isJson)
|
|
785
|
+
continue;
|
|
786
|
+
try {
|
|
787
|
+
const raw = readFileSync(entrypointPath, "utf-8");
|
|
788
|
+
const parsed = (isYaml ? parseYaml(raw) : JSON.parse(raw));
|
|
789
|
+
if (parsed.trigger?.type !== "webhook")
|
|
790
|
+
continue;
|
|
791
|
+
if (parsed.trigger.path === requestPath) {
|
|
792
|
+
return {
|
|
793
|
+
name: parsed.name ??
|
|
794
|
+
path.basename(entrypointPath, path.extname(entrypointPath)),
|
|
795
|
+
path: requestPath,
|
|
796
|
+
filePath: entrypointPath,
|
|
797
|
+
format: isYaml ? "yaml" : "json",
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
catch {
|
|
802
|
+
// skip malformed
|
|
803
|
+
}
|
|
804
|
+
}
|
|
390
805
|
return null;
|
|
391
806
|
}
|
|
392
807
|
/**
|