Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
79fbcdc
feat: add firebase ailogic:providers CLI commands
marb2000 Jul 8, 2026
f314b73
Merge branch 'main' into feat/ailogic-providers
marb2000 Jul 8, 2026
6621514
Merge branch 'main' into feat/ailogic-providers
marb2000 Jul 8, 2026
f93655f
Merge branch 'main' into feat/ailogic-providers
marb2000 Jul 10, 2026
dc501fe
fix(ailogic): address PR review feedback
marb2000 Jul 18, 2026
089c274
Merge remote-tracking branch 'origin/main' into feat/ailogic-providers
marb2000 Jul 22, 2026
4273fe0
style: format CHANGELOG.md with prettier
marb2000 Jul 22, 2026
f06ed08
feat: add firebase ailogic:config CLI commands
marb2000 Jul 8, 2026
847f078
fix(ailogic): address review feedback on config commands
marb2000 Jul 18, 2026
dd7c215
fix(ailogic): polish config commands per pre-PR review
marb2000 Jul 23, 2026
6eb7b69
fix(ailogic): address code review on config PR
marb2000 Jul 23, 2026
86e99a0
refactor(ailogic): reuse shared config helpers and remove unused code
marb2000 Jul 23, 2026
dd1d94e
fix(ailogic): address bug-hunt findings on config commands
marb2000 Jul 23, 2026
3e9f376
fix(ailogic): address review nits from joehan
marb2000 Jul 27, 2026
5187f7a
fix(ailogic): keep provider state consistent with the AI Logic API
marb2000 Jul 27, 2026
5e69ca9
Merge feat/ailogic-providers: keep provider state consistent with the…
marb2000 Jul 27, 2026
79aa708
refactor(ailogic): drop namespace help listing superseded by progress…
marb2000 Jul 27, 2026
8e9688d
Merge feat/ailogic-providers: drop namespace help listing superseded …
marb2000 Jul 27, 2026
9d0abb4
feat: add firebase ailogic templates CLI commands
marb2000 Jul 8, 2026
f432cd0
fix(ailogic): address review feedback on template commands
marb2000 Jul 18, 2026
655433b
refactor(ailogic): restructure template deploy around a pure planner;…
marb2000 Jul 23, 2026
612ffdd
fix(ailogic): polish template commands per accumulated review feedback
marb2000 Jul 27, 2026
6a69161
fix(ailogic): harden template commands per pre-PR bug hunt
marb2000 Jul 27, 2026
aacf0a5
fix(ailogic): use the ModifyLock RPC and etag preconditions for templ…
marb2000 Jul 27, 2026
50b9c3a
fix(ailogic): address review findings and polish template deploy sema…
marb2000 Jul 27, 2026
9d8f704
Merge remote-tracking branch 'upstream/main' into feat/ailogic-templates
marb2000 Jul 28, 2026
b11dda0
feat(ailogic): deploy templates via firebase deploy --only ailogic
marb2000 Jul 30, 2026
1b16f47
Merge branch 'main' into feat/ailogic-templates
joehan Jul 31, 2026
b9a8f75
fix(ailogic): gate deploys at the command level per review
marb2000 Jul 31, 2026
38053e9
Merge remote-tracking branch 'upstream/feat/ailogic-templates' into f…
marb2000 Jul 31, 2026
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
39 changes: 39 additions & 0 deletions schema/firebase-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,42 @@
],
"type": "string"
},
"AiLogicConfig": {
"additionalProperties": false,
"properties": {
"postdeploy": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
]
},
"predeploy": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
]
},
"templates": {
"description": "Directory of .prompt files deployed as server prompt templates (default: \"prompts\").",
"type": "string"
}
},
"type": "object"
},
"AuthConfig": {
"additionalProperties": false,
"properties": {
Expand Down Expand Up @@ -1231,6 +1267,9 @@
"$schema": {
"type": "string"
},
"ailogic": {
"$ref": "#/definitions/AiLogicConfig"
},
"apphosting": {
"anyOf": [
{
Expand Down
192 changes: 192 additions & 0 deletions src/ailogic/templates.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { expect } from "chai";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";

import { Template } from "../gcp/ailogic";
import { planTemplateDeploy, readPromptDirectory, validatePromptFile } from "./templates";

function remote(id: string, locked = false): Template {
return { name: `projects/p/locations/global/templates/${id}`, templateString: id, locked };
}

describe("ailogic templates", () => {
describe("validatePromptFile", () => {
it("rejects an empty file", () => {
expect(validatePromptFile("")).to.match(/empty/);
expect(validatePromptFile(" \n ")).to.match(/empty/);
});

it("accepts a body with no frontmatter", () => {
expect(validatePromptFile("Just a prompt body.")).to.be.null;
});

it("accepts valid frontmatter plus body", () => {
expect(validatePromptFile("---\nmodel: gemini\n---\nbody")).to.be.null;
});

it("accepts empty frontmatter and CRLF line endings", () => {
expect(validatePromptFile("---\n---\nbody")).to.be.null;
expect(validatePromptFile("---\r\nmodel: gemini\r\n---\r\nbody")).to.be.null;
});

it("rejects unterminated frontmatter", () => {
expect(validatePromptFile("---\nmodel: gemini\nbody")).to.match(/not closed/);
expect(validatePromptFile("---")).to.match(/not closed/);
});

it("does not mistake '---' inside a quoted YAML value for the delimiter", () => {
expect(validatePromptFile('---\nkey: "a --- b"\n---\nbody')).to.be.null;
});

it("does not mistake '---' in the body for a delimiter", () => {
expect(validatePromptFile("---\nmodel: gemini\n---\nintro\n---\nmore body")).to.be.null;
});

it("rejects invalid YAML and non-mapping frontmatter", () => {
expect(validatePromptFile("---\nkey: [unclosed\n---\nbody")).to.match(/Invalid YAML/);
expect(validatePromptFile("---\njust a string\n---\nbody")).to.match(/YAML mapping/);
// Arrays are typeof "object" but are not mappings.
expect(validatePromptFile("---\n- a\n- b\n---\nbody")).to.match(/YAML mapping/);
});
});

describe("readPromptDirectory", () => {
let dir: string;

beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), "ailogic-prompts-"));
});

afterEach(() => fs.rmSync(dir, { recursive: true, force: true }));

// Writes real files/directories; null content marks a directory entry.
function makeDir(files: Record<string, string | null>): void {
for (const [rel, content] of Object.entries(files)) {
const abs = path.join(dir, rel);
if (content === null) {
fs.mkdirSync(abs, { recursive: true });
} else {
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, content);
}
}
}

it("collects templates and reports every error in one pass", () => {
makeDir({
"welcome.prompt": "hello",
"empty.prompt": "",
"bad id.prompt": "body",
"folder.prompt": null,
"notes.txt": "ignored",
});

const result = readPromptDirectory(dir);

expect([...result.templates.keys()]).to.deep.equal(["welcome"]);
expect(result.errors.map((e) => e.file).sort()).to.deep.equal([
"bad id.prompt",
"empty.prompt",
"folder.prompt",
]);
});

it("rejects a file named exactly '.prompt' (empty template id)", () => {
makeDir({ ".prompt": "body" });
const result = readPromptDirectory(dir);
expect(result.templates.size).to.equal(0);
expect(result.errors[0].error).to.match(/valid template id/);
});

it("matches the extension case-insensitively, preserving the id's case", () => {
makeDir({ "Upper.PROMPT": "body" });
const result = readPromptDirectory(dir);
expect([...result.templates.keys()]).to.deep.equal(["Upper"]);
expect(result.errors).to.deep.equal([]);
});

it("reads subfolders recursively, flattening paths into dotted ids", () => {
makeDir({
"welcome.prompt": "hi",
"agents/support.prompt": "support body",
"agents/deep/triage.prompt": "triage body",
});

const result = readPromptDirectory(dir);

expect([...result.templates.keys()].sort()).to.deep.equal([
"agents.deep.triage",
"agents.support",
"welcome",
]);
expect(result.errors).to.deep.equal([]);
});

it("reports a nested file colliding with a dotted flat file, naming both", () => {
makeDir({
"agents/support.prompt": "nested",
"agents.support.prompt": "flat",
});

const result = readPromptDirectory(dir);

expect(result.templates.size).to.equal(1);
expect(result.errors).to.have.length(1);
expect(result.errors[0].error).to.match(
/Duplicate template id 'agents\.support' \(also from /,
);
});
});

describe("planTemplateDeploy", () => {
const local = new Map([
["welcome", "hello"],
["fresh", "new"],
]);

it("splits creates and updates", () => {
const plan = planTemplateDeploy(local, [remote("welcome")], false);
expect(plan).to.deep.equal({
creates: ["fresh"],
updates: ["welcome"],
unchanged: [],
deletes: [],
lockedViolations: [],
});
});

it("skips templates whose content matches the remote", () => {
// remote() uses the id as the templateString, so this local content matches.
const matching = new Map([["welcome", "welcome"]]);
const plan = planTemplateDeploy(matching, [remote("welcome")], false);
expect(plan.unchanged).to.deep.equal(["welcome"]);
expect(plan.updates).to.deep.equal([]);
});

it("does not flag a locked template as a violation when its content is unchanged", () => {
const matching = new Map([["welcome", "welcome"]]);
const plan = planTemplateDeploy(matching, [remote("welcome", true)], false);
expect(plan.unchanged).to.deep.equal(["welcome"]);
expect(plan.lockedViolations).to.deep.equal([]);
});

it("flags a locked update target as a violation, not an update", () => {
const plan = planTemplateDeploy(local, [remote("welcome", true)], false);
expect(plan.updates).to.deep.equal([]);
expect(plan.lockedViolations).to.deep.equal(["welcome"]);
});

it("prunes unlocked remote-only templates and flags locked ones", () => {
const plan = planTemplateDeploy(local, [remote("stale"), remote("guarded", true)], true);
expect(plan.deletes).to.deep.equal(["stale"]);
expect(plan.lockedViolations).to.deep.equal(["guarded"]);
});

it("does not delete anything without prune", () => {
const plan = planTemplateDeploy(local, [remote("stale")], false);
expect(plan.deletes).to.deep.equal([]);
expect(plan.lockedViolations).to.deep.equal([]);
});
});
});
Loading
Loading