Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/docs/Advanced/scriptsWithSettings.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ module.exports = {
placeholder: "Placeholder",
description: "Description here.",
},
"API Key": {
type: "secret",
placeholder: "Paste API key",
description: "Stored securely with Obsidian SecretStorage.",
},
"Checkbox": {
type: "checkbox",
defaultValue: false,
Expand All @@ -71,6 +76,7 @@ module.exports = {

## Setting types
- `text` and `input`: A text field.
- `secret`: A password-style input stored with Obsidian SecretStorage. QuickAdd stores only a reference in `data.json`; package exports omit secret values and local secret references. The older `text` / `input` plus `secret: true` form is still treated as a secret setting.
- `textarea`: A multi-line text area.
- `checkbox` and `toggle`: A checkbox.
- `dropdown` and `select`: A dropdown.
Expand Down
30 changes: 22 additions & 8 deletions docs/docs/UserScripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,20 +213,36 @@ User scripts can define configurable options that users can set through the Quic
### Option Types

#### Text Input
For string values, API keys, paths, etc.
For regular string values, paths, names, etc.

```javascript
options: {
"API Key": {
"Project": {
type: "text", // or "input"
defaultValue: "",
placeholder: "Enter API key",
secret: true, // Optional: masks input for sensitive data
description: "Your API key" // Optional: help text
placeholder: "Project name",
description: "Default project" // Optional: help text
}
}
```

#### Secret Input
For API keys, access tokens, and other sensitive values. Secrets are stored in Obsidian's SecretStorage and QuickAdd stores only a reference in `data.json`.

```javascript
options: {
"API Key": {
type: "secret",
placeholder: "Paste API key",
description: "Your API key"
}
}
```

`type: "text"` / `type: "input"` with `secret: true` is still supported for older scripts and is treated as a secret setting.

Secret values are local to the Obsidian app profile. They are not included in QuickAdd package exports and are not synced through the plugin's `data.json`; users must enter them on each device where the script runs.

#### Toggle/Checkbox
For boolean on/off settings.

Expand Down Expand Up @@ -280,10 +296,8 @@ module.exports = {
author: "Your Name",
options: {
"API Key": {
type: "text",
defaultValue: "",
type: "secret",
placeholder: "sk-...",
secret: true,
description: "OpenAI API key"
},
"Model": {
Expand Down
51 changes: 51 additions & 0 deletions src/engine/MacroChoiceEngine.entry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,57 @@ describe("MacroChoiceEngine user script entry handling", () => {
);
});

it("migrates legacy plaintext secret settings before invoking the entry export", async () => {
const entryFn = vi.fn().mockResolvedValue("entry-result");
const secretStore = new Map<string, string>();
const secretApp = {
secretStorage: {
getSecret: vi.fn((name: string) => secretStore.get(name) ?? null),
setSecret: vi.fn((name: string, value: string) => {
secretStore.set(name, value);
}),
},
} as unknown as App;
const saveSettings = vi.fn().mockResolvedValue(undefined);
const pluginWithSave = { saveSettings } as unknown as QuickAdd;
userScriptCommand.settings = {
"API Key": "legacy-secret",
};

mockGetUserScript.mockResolvedValue({
entry: entryFn,
settings: {
options: {
"API Key": {
type: "secret",
},
},
},
});

const engine = new MacroChoiceEngine(
secretApp,
pluginWithSave,
macroChoice,
choiceExecutor,
variables,
);

await engine["executeUserScript"](userScriptCommand);

expect(secretStore.get("quickadd-user-script-user-script-api-key")).toBe(
"legacy-secret",
);
expect(JSON.stringify(userScriptCommand.settings)).not.toContain(
"legacy-secret",
);
expect(entryFn).toHaveBeenCalledWith(
expect.objectContaining({ variables: expect.any(Object) }),
{ "API Key": "legacy-secret" },
);
expect(saveSettings).toHaveBeenCalledTimes(1);
});

it("ignores malformed primitive settings exports at the user-script boundary", async () => {
const entryFn = vi.fn().mockResolvedValue("entry-result");

Expand Down
37 changes: 33 additions & 4 deletions src/engine/MacroChoiceEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ import { TFile } from "obsidian";
import { MacroAbortError } from "../errors/MacroAbortError";
import { UserCancelError } from "../errors/UserCancelError";
import { initializeUserScriptSettings } from "../utils/userScriptSettings";
import {
migrateUserScriptSecretSettings,
resolveUserScriptSettings,
type UserScriptSettingsDefinition,
} from "../utils/userScriptSecrets";
import type { IConditionalCommand } from "../types/macros/Conditional/IConditionalCommand";
import type { ScriptCondition } from "../types/macros/Conditional/types";
import { evaluateCondition } from "./helpers/conditionalEvaluator";
Expand Down Expand Up @@ -162,6 +167,7 @@ export class MacroChoiceEngine extends QuickAddChoiceEngine {
protected choiceExecutor: IChoiceExecutor;
protected readonly plugin: QuickAdd;
private userScriptCommand: IUserScript | null;
private userScriptSettingsDefinition: UserScriptSettingsDefinition | undefined;
private conditionalScriptCache = new Map<string, ConditionalScriptRunner>();
private readonly preloadedUserScripts: Map<string, unknown>;
private readonly promptLabel?: string;
Expand Down Expand Up @@ -248,6 +254,7 @@ export class MacroChoiceEngine extends QuickAddChoiceEngine {
this.choiceExecutor = choiceExecutor;
this.preloadedUserScripts = preloadedUserScripts ?? new Map();
this.promptLabel = promptLabel;
this.userScriptSettingsDefinition = undefined;
const sharedVariables = this.initSharedVariables(
choiceExecutor,
variables
Expand Down Expand Up @@ -340,13 +347,13 @@ export class MacroChoiceEngine extends QuickAddChoiceEngine {
command.settings = {};
}

this.userScriptCommand = command;

const userScriptSettings = getUserScriptSettings(userScript);
if (userScriptSettings) {
// Initialize default values for settings before executing the script
initializeUserScriptSettings(command.settings, userScriptSettings);
}
this.userScriptCommand = command;
this.userScriptSettingsDefinition = userScriptSettings;

try {
await this.userScriptDelegator(userScript);
Expand All @@ -359,9 +366,28 @@ export class MacroChoiceEngine extends QuickAddChoiceEngine {
throw err;
} finally {
this.userScriptCommand = null;
this.userScriptSettingsDefinition = undefined;
}
}

private async getResolvedUserScriptSettings(command: IUserScript) {
if (
await migrateUserScriptSecretSettings(
this.app,
command,
this.userScriptSettingsDefinition,
)
) {
await this.plugin.saveSettings?.();
}

return resolveUserScriptSettings(
this.app,
command,
this.userScriptSettingsDefinition,
);
}

private async runScriptWithSettings(
userScript:
| ((
Expand All @@ -383,12 +409,15 @@ export class MacroChoiceEngine extends QuickAddChoiceEngine {
) {
return await this.onExportIsFunction(
userScript.entry,
command.settings
await this.getResolvedUserScriptSettings(command),
);
}

if (typeof userScript === "function") {
return await this.onExportIsFunction(userScript, command.settings);
return await this.onExportIsFunction(
userScript,
await this.getResolvedUserScriptSettings(command),
);
}
}

Expand Down
102 changes: 102 additions & 0 deletions src/engine/SingleMacroEngine.member-access.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,108 @@ describe("SingleMacroEngine member access", () => {
expect(choiceExecutor.variables.get("newValue")).toBe("hello");
});

it("migrates legacy plaintext secret settings before member-access execution", async () => {
const secretStore = new Map<string, string>();
const secretApp = {
secretStorage: {
getSecret: vi.fn((name: string) => secretStore.get(name) ?? null),
setSecret: vi.fn((name: string, value: string) => {
secretStore.set(name, value);
}),
},
} as unknown as App;
const saveSettings = vi.fn().mockResolvedValue(undefined);
const pluginWithSave = { saveSettings } as unknown as QuickAdd;
const userScript = createUserScript("user-script", "script.js");
userScript.settings = {
"API Key": "legacy-secret",
};
const macroChoice = baseMacroChoice([userScript]);
const choices: IChoice[] = [macroChoice];

const engineInstance = macroEngineFactory();
macroEngineFactory = () => engineInstance;
const exportFn = vi.fn().mockReturnValue("secret-result");

mockGetUserScript.mockResolvedValue({
settings: {
options: {
"API Key": {
type: "secret",
},
},
},
f: exportFn,
});

const engine = new SingleMacroEngine(
secretApp,
pluginWithSave,
choices,
choiceExecutor,
);

await engine.runAndGetOutput("My Macro::f");

expect(secretStore.get("quickadd-user-script-user-script-api-key")).toBe(
"legacy-secret",
);
expect(JSON.stringify(userScript.settings)).not.toContain("legacy-secret");
expect(exportFn).toHaveBeenCalledWith(engineInstance.params, {
"API Key": "legacy-secret",
});
expect(saveSettings).toHaveBeenCalledTimes(1);
});

it("does not resolve or migrate settings for non-callable member access", async () => {
const getSecret = vi.fn(() => {
throw new Error("should not read secrets for metadata access");
});
const setSecret = vi.fn();
const secretApp = {
secretStorage: {
getSecret,
setSecret,
},
} as unknown as App;
const saveSettings = vi.fn().mockResolvedValue(undefined);
const pluginWithSave = { saveSettings } as unknown as QuickAdd;
const userScript = createUserScript("user-script", "script.js");
userScript.settings = {
"API Key": {
__quickaddSecret: true,
secretRef: "missing-secret",
},
};
const macroChoice = baseMacroChoice([userScript]);
const choices: IChoice[] = [macroChoice];
const exportedSettings = {
options: {
"API Key": {
type: "secret",
},
},
};

mockGetUserScript.mockResolvedValue({
settings: exportedSettings,
});

const engine = new SingleMacroEngine(
secretApp,
pluginWithSave,
choices,
choiceExecutor,
);

const result = await engine.runAndGetOutput("My Macro::settings");

expect(result).toBe(JSON.stringify(exportedSettings));
expect(getSecret).not.toHaveBeenCalled();
expect(setSecret).not.toHaveBeenCalled();
expect(saveSettings).not.toHaveBeenCalled();
});

it("reaches a unique member on a later script while preserving pre and post execution", async () => {
const preCommand = {
id: "wait-1",
Expand Down
27 changes: 26 additions & 1 deletion src/engine/SingleMacroEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import { CommandType } from "../types/macros/CommandType";
import { getUserScript, getUserScriptMemberAccess } from "../utilityObsidian";
import { flattenChoices } from "../utils/choiceUtils";
import { initializeUserScriptSettings } from "../utils/userScriptSettings";
import {
migrateUserScriptSecretSettings,
resolveUserScriptSettings,
} from "../utils/userScriptSecrets";
import { MacroChoiceEngine } from "./MacroChoiceEngine";
import { handleMacroAbort } from "../utils/macroAbortHandler";
import { MacroAbortError } from "../errors/MacroAbortError";
Expand Down Expand Up @@ -299,11 +303,32 @@ export class SingleMacroEngine {
}

const postCommands = updatedCommands.slice(refreshedIndex + 1);
let memberSettings = userScriptCommand.settings;
if (typeof resolvedMember.value === "function") {
if (
await migrateUserScriptSecretSettings(
this.app,
userScriptCommand,
settingsExport && typeof settingsExport === "object"
? (settingsExport as Record<string, unknown>)
: undefined,
)
) {
await this.plugin.saveSettings?.();
}
memberSettings = await resolveUserScriptSettings(
this.app,
userScriptCommand,
settingsExport && typeof settingsExport === "object"
? (settingsExport as Record<string, unknown>)
: undefined,
);
}

const result = await this.executeResolvedMember(
resolvedMember.value,
engine,
userScriptCommand.settings,
memberSettings,
);
this.ensureNotAborted();

Expand Down
Loading