Compare commits

...
Author SHA1 Message Date
netcatty-bot 0537417793 fix: address Codex review on PR #3636 2026-10-11 21:25:54 +00:00
netcatty-bot 86c3419210 fix(#3635): automated AI fix 2026-10-11 20:58:01 +00:00
2 changed files with 119 additions and 11 deletions

No files matched your search

+73
View File
@@ -280,3 +280,76 @@ test("mergeSnippetImportPayload keeps existing empty snippet packages", () => {
assert.deepEqual(result.snippetPackages, ["local-empty", "imported", "imported/ops"]);
});
test("snippet export and import preserve the all-hosts scope", () => {
const exported = buildSnippetExportPayload({
snippets: [
snippet({
id: "all-hosts",
label: "Patch everything",
command: "apt-get update",
targetsAllHosts: true,
}),
],
snippetPackages: [],
exportedAt: "2026-10-11T00:00:00.000Z",
});
// New exports carry the all-hosts scope, so they must advertise a version
// that pre-change releases do not silently accept while dropping the field.
assert.equal(exported.version, 3);
const parsed = parseSnippetImportPayload(JSON.stringify(exported));
const result = mergeSnippetImportPayload({
existingSnippets: [],
existingSnippetPackages: [],
payload: parsed,
conflictAction: "skip",
createId: () => "imported-all-hosts",
});
assert.equal(exported.snippets[0].targetsAllHosts, true);
assert.equal("targets" in exported.snippets[0], false);
assert.equal(parsed.snippets[0].targetsAllHosts, true);
assert.equal(result.snippets[0].targetsAllHosts, true);
assert.deepEqual(result.snippets[0].targets, []);
assert.equal(result.snippets[0].targetGroups, undefined);
});
test("snippet import resolves all-hosts scope against dynamic groups", () => {
const parsed = parseSnippetImportPayload(JSON.stringify({
kind: "netcatty.snippets",
version: 2,
exportedAt: "2026-10-11T00:00:00.000Z",
snippetPackages: [],
snippets: [
{
label: "All hosts beats groups",
command: "uptime",
targetGroups: ["Production"],
targetsAllHosts: true,
},
{
label: "Explicit false stays unscoped",
command: "whoami",
targetsAllHosts: false,
},
],
}));
assert.equal(parsed.snippets[0].targetsAllHosts, true);
assert.equal(parsed.snippets[0].targetGroups, undefined);
assert.deepEqual(parsed.snippets[0].targetGroups ?? [], []);
assert.deepEqual([...(parsed.snippets[0].tags ?? [])], []);
assert.equal(parsed.snippets[1].targetsAllHosts, undefined);
const merged = mergeSnippetImportPayload({
existingSnippets: [],
existingSnippetPackages: [],
payload: parsed,
conflictAction: "skip",
createId: () => "import-1",
});
assert.equal(merged.snippets[0].targetsAllHosts, true);
assert.deepEqual(merged.snippets[0].targetGroups, undefined);
assert.equal(merged.snippets[1].targetsAllHosts, undefined);
assert.deepEqual(merged.snippets[1].targetGroups, undefined);
});
+46 -11
View File
@@ -4,7 +4,11 @@ import { normalizeVaultOrder } from "./vaultOrder";
import { normalizeGroupTargetPaths } from "./hostGroupPathMutations";
export const SNIPPET_EXPORT_KIND = "netcatty.snippets" as const;
export const SNIPPET_EXPORT_VERSION = 2 as const;
// Version 3 introduced the portable all-hosts scope (targetsAllHosts); older
// releases silently drop that field when they accept a version-2 payload, so
// new exports advertise 3 while imports still accept versions 1 and 2.
export const SNIPPET_EXPORT_VERSION = 3 as const;
export const SNIPPET_EXPORT_VERSION_PREVIOUS = 2 as const;
export const SNIPPET_EXPORT_VERSION_LEGACY = 1 as const;
export type SnippetImportConflictAction = "skip" | "overwrite";
@@ -15,6 +19,7 @@ export type SnippetExportItem = {
tags?: string[];
package?: string;
targetGroups?: string[];
targetsAllHosts?: boolean;
shortkey?: string;
noAutoRun?: boolean;
multiLineRunMode?: Snippet["multiLineRunMode"];
@@ -27,7 +32,10 @@ export type SnippetExportItem = {
export type SnippetExportPayload = {
kind: typeof SNIPPET_EXPORT_KIND;
version: typeof SNIPPET_EXPORT_VERSION | typeof SNIPPET_EXPORT_VERSION_LEGACY;
version:
| typeof SNIPPET_EXPORT_VERSION
| typeof SNIPPET_EXPORT_VERSION_PREVIOUS
| typeof SNIPPET_EXPORT_VERSION_LEGACY;
exportedAt: string;
snippetPackages: string[];
snippets: SnippetExportItem[];
@@ -88,12 +96,20 @@ const mergeSnippetPackagePaths = (...groups: string[][]): string[] => {
return uniqueStrings(paths);
};
/**
* Concrete host-id bindings (targets) are intentionally omitted for portability;
* the portable all-hosts scope rides along and suppresses group scope on import.
*/
const toExportItem = (snippet: Snippet): SnippetExportItem => ({
label: snippet.label,
command: snippet.command,
tags: Array.isArray(snippet.tags) ? [...snippet.tags] : [],
package: snippet.package || "",
...(snippet.targetGroups !== undefined ? { targetGroups: [...snippet.targetGroups] } : {}),
...(snippet.targetsAllHosts
? { targetsAllHosts: true }
: snippet.targetGroups !== undefined
? { targetGroups: [...snippet.targetGroups] }
: {}),
...(snippet.shortkey ? { shortkey: snippet.shortkey } : {}),
...(snippet.noAutoRun ? { noAutoRun: snippet.noAutoRun } : {}),
...(snippet.multiLineRunMode ? { multiLineRunMode: snippet.multiLineRunMode } : {}),
@@ -134,6 +150,8 @@ const sanitizeImportItem = (value: unknown): SnippetExportItem | null => {
const label = typeof value.label === "string" && value.label.trim()
? value.label.trim()
: fallbackLabel(value.command);
// "Apply to all hosts" is a portable scope; when set it wins over group scope.
const targetsAllHosts = value.targetsAllHosts === true;
return {
label,
command: value.command,
@@ -141,11 +159,14 @@ const sanitizeImportItem = (value: unknown): SnippetExportItem | null => {
? uniqueStrings(value.tags)
: [],
package: typeof value.package === "string" ? value.package.trim() : "",
targetGroups: Array.isArray(value.targetGroups)
? normalizeGroupTargetPaths(value.targetGroups.filter(
(entry): entry is string => typeof entry === "string",
))
: undefined,
targetGroups: targetsAllHosts
? undefined
: Array.isArray(value.targetGroups)
? normalizeGroupTargetPaths(value.targetGroups.filter(
(entry): entry is string => typeof entry === "string",
))
: undefined,
targetsAllHosts: targetsAllHosts || undefined,
shortkey: typeof value.shortkey === "string" && value.shortkey.trim()
? value.shortkey.trim()
: undefined,
@@ -174,7 +195,11 @@ const parseSnippetImportObject = (parsed: Record<string, unknown>): SnippetExpor
if (parsed.kind !== SNIPPET_EXPORT_KIND) {
throw new Error("Unsupported snippet import file.");
}
if (version !== SNIPPET_EXPORT_VERSION && version !== SNIPPET_EXPORT_VERSION_LEGACY) {
if (
version !== SNIPPET_EXPORT_VERSION &&
version !== SNIPPET_EXPORT_VERSION_PREVIOUS &&
version !== SNIPPET_EXPORT_VERSION_LEGACY
) {
throw new Error("Unsupported snippet import file.");
}
if (!Array.isArray(parsed.snippets)) {
@@ -187,7 +212,12 @@ const parseSnippetImportObject = (parsed: Record<string, unknown>): SnippetExpor
return {
kind: SNIPPET_EXPORT_KIND,
version: version === SNIPPET_EXPORT_VERSION ? SNIPPET_EXPORT_VERSION : SNIPPET_EXPORT_VERSION_LEGACY,
version:
version === SNIPPET_EXPORT_VERSION
? SNIPPET_EXPORT_VERSION
: version === SNIPPET_EXPORT_VERSION_PREVIOUS
? SNIPPET_EXPORT_VERSION_PREVIOUS
: SNIPPET_EXPORT_VERSION_LEGACY,
exportedAt: typeof parsed.exportedAt === "string" ? parsed.exportedAt : "",
snippetPackages: collectSnippetPackagePaths(
snippets,
@@ -244,8 +274,13 @@ const toImportedSnippet = (item: SnippetExportItem, id: string, order?: number):
command: item.command,
tags: item.tags || [],
package: item.package || "",
// Host-id bindings are never imported; the all-hosts flag wins over group scope.
targets: [],
...(item.targetGroups !== undefined ? { targetGroups: [...item.targetGroups] } : {}),
...(item.targetsAllHosts
? { targetsAllHosts: true }
: item.targetGroups !== undefined
? { targetGroups: [...item.targetGroups] }
: {}),
...(item.shortkey ? { shortkey: item.shortkey } : {}),
...(item.noAutoRun ? { noAutoRun: item.noAutoRun } : {}),
...(item.multiLineRunMode ? { multiLineRunMode: item.multiLineRunMode } : {}),