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
4 changes: 3 additions & 1 deletion cli/src/commands/kb.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,14 @@ Usage
dataops kb recent [--limit N] Recently changed docs, newest first: one row
per doc with who changed it and how often
dataops kb write <path> --file F [--create] [--title T] [--type T] [--dry-run]
Publish new content (as the web editor does)
dataops kb regenerate --all|<id> Regenerate task views, indexes, and system pages
dataops kb deprecate <doc-id> [--replaced-by ID]
dataops kb archive <doc-id> Mark a document archived
dataops kb create <path> --from <doc-id> [--title T] [--sop TARGET-SOP-ID]
Copy an existing document to a new path
dataops kb export <doc-id> [--format md|docx|txt] [--output PATH]
Publish new content (as the web editor does)
Download a document; docx needs --output
dataops kb import <path> --file F --source GOOGLE_DOC_URL --id ID --title T
[--type template|reference] [--format text|markdown] [--output F]
Import an authenticated Google Docs export;
Expand Down
6 changes: 3 additions & 3 deletions cli/src/commands/sponsorDocs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -168,10 +168,10 @@ export async function create(args, io) {
const prefix = commandPrefix(args);
const createUsage =
SPEAKER_DOMAINS.includes(domain)
? `${prefix} create <card-id> [--speaker NAME] [--date YYYY-MM-DD] [--email]`
? `${prefix} create <card-id> [--from DOC-ID] [--speaker NAME] [--date YYYY-MM-DD] [--email]`
: domain === "podcast"
? `${prefix} create <card-id> [--template ${PODCAST_TEMPLATES.join("|")}] [--sponsor NAME] [--date YYYY-MM-DD] [--email]`
: `${prefix} create <issue-id|card-id> [--slot primary|secondary] [--standalone] [--email]`;
? `${prefix} create <card-id> [--template ${PODCAST_TEMPLATES.join("|")}] [--from DOC-ID] [--sponsor NAME] [--date YYYY-MM-DD] [--email]`
: `${prefix} create <issue-id|card-id> [--slot primary|secondary] [--standalone] [--from DOC-ID] [--email]`;

// Defensive recovery if a boolean flag (--standalone or --email) was placed immediately before the positional ID
const positionals = [...(args._ || [])];
Expand Down
56 changes: 56 additions & 0 deletions cli/test/cli.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -777,6 +777,62 @@ describe("newsletter doc and podcast doc commands", () => {
globalThis.fetch = globalFetch;
});

test("sponsor doc create --from sends the copy source and keeps domain guards", async () => {
saveProfile("https://ops.dtcdev.click", { token: "dops_abc", tokenId: "id-1" });
const sent = [];
const fetch = fakeFetch({
"/api/sponsor-docs": (options) => {
if (options.method === "POST") {
const parsed = JSON.parse(options.body);
sent.push(parsed);
return {
status: 201,
body: {
doc: {
id: "doc-copy",
cardId: parsed.cardId,
template: parsed.template || "copied-from-source",
status: "draft",
version: 1,
docUrl: "https://ops.dtcdev.click/sponsor-docs/doc-copy",
shareUrl: "https://ops.dtcdev.click/sponsor-docs/doc-copy?code=CODE",
accessCode: "CODE",
},
},
};
}
return { body: { docs: [] } };
},
});
globalThis.fetch = fetch;

// A podcast copy without --template lets the server inherit the source template.
const ioPodcast = captureIo();
assert.equal(
await run(["podcast", "doc", "create", "card-pod-2", "--from", "https://ops.dtcdev.click/podcast-docs/source-doc", "--url", "https://ops.dtcdev.click"], ioPodcast),
0,
);
assert.deepEqual(sent[0], { cardId: "card-pod-2", from: "source-doc" });

const ioWebinar = captureIo();
assert.equal(
await run(["webinar", "doc", "create", "card-web-1", "--from", "source-webinar", "--url", "https://ops.dtcdev.click"], ioWebinar),
0,
);
assert.deepEqual(sent[1], { cardId: "card-web-1", template: "webinar", from: "source-webinar" });

// --from does not bypass the speaker-domain guards: newsletter slot flags stay non-applicable.
const ioSlot = captureIo();
assert.equal(
await run(["webinar", "doc", "create", "card-web-1", "--from", "source-webinar", "--slot", "primary", "--url", "https://ops.dtcdev.click"], ioSlot),
2,
);
assert.match(ioSlot.errors.join("\n"), /newsletter slot flags do not apply/);
assert.equal(sent.length, 2);

globalThis.fetch = globalFetch;
});

test("newsletter markdown set, show, preview, and rotate-code store and print shareable preview links", async () => {
const { writeFileSync } = await import("node:fs");
const issuePath = path.join(configDir, "issue.md");
Expand Down
48 changes: 48 additions & 0 deletions cli/test/kb.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -359,3 +359,51 @@ describe('kb template validation before mutation', () => {
} finally { rmSync(dir,{recursive:true,force:true}); }
});
});

describe("kb create --from and kb export", () => {
test("copy posts the source reference at the head revision", async () => {
const calls = fakePortal({
"GET /knowledge/publication": { revision: "rev-head" },
"POST /docs": ({ body }) => ({ status: 201, body: { path: body.path, revision: "rev-copy" } }),
});
const io = captureIo();
assert.equal(await run(["kb", "create", "content/newsletter/copied.md", "--from", "news.ref", "--title", "Copied reference"], io), 0);
const post = calls.find((call) => call.method === "POST");
assert.deepEqual(post.body, { path: "content/newsletter/copied.md", from: "news.ref", title: "Copied reference", absent: true, expectedRevision: "rev-head" });
assert.match(io.text(), /Created content\/newsletter\/copied\.md \(revision rev-copy\)\./);
});

test("copy without --from is a usage error and calls nothing", async () => {
const calls = fakePortal({});
const io = captureIo();
assert.equal(await run(["kb", "create", "content/newsletter/copied.md"], io), 2);
assert.match(io.errors.join("\n"), /--from <doc-id> is required/);
assert.equal(calls.length, 0);
});

test("export prints markdown by default and rejects unsupported formats", async () => {
const calls = fakePortal({ "GET /docs/export": () => new Response("# Copied reference\n") });
const io = captureIo();
assert.equal(await run(["kb", "export", "news.ref"], io), 0);
assert.equal(calls[0].query.ref, "news.ref");
assert.equal(calls[0].query.format, "md");
assert.equal(io.text(), "# Copied reference\n");
const bad = captureIo();
assert.equal(await run(["kb", "export", "news.ref", "--format", "pdf"], bad), 2);
assert.match(bad.errors.join("\n"), /--format must be md, docx, or txt/);
});

test("docx export writes the binary bytes to --output", async () => {
const dir = mkdtempSync(path.join(tmpdir(), "kb-export-"));
try {
const bytes = Buffer.from([80, 75, 3, 4, 0, 255, 128, 42]);
const calls = fakePortal({ "GET /docs/export": () => new Response(bytes) });
const output = path.join(dir, "doc.docx");
const io = captureIo();
assert.equal(await run(["kb", "export", "news.ref", "--format", "docx", "--output", output], io), 0);
assert.equal(calls[0].query.format, "docx");
assert.deepEqual(readFileSync(output), bytes);
assert.match(io.text(), /Saved .*doc\.docx \(8 bytes\)\./);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
});