@zalify/cli 0.20.0 → 0.21.0

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.
Files changed (3) hide show
  1. package/README.md +1 -0
  2. package/dist/cli.js +129 -31
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -102,6 +102,7 @@ its new home under `shop`.
102
102
  | `ads create <dir>` | Provision an Autopilot brand-instance GitHub repo (POST `/api/repos`, GitHub App), scaffold the starter locally (`identity.yaml`, `brand.yaml`, `policy/`, `campaigns/`, `media-plans/`, `signals/`, `decisions/`), and push with a short-lived repo-scoped token. Writes `.zalify/brand-repo.json` so later commands run arg-free. The directory name becomes the slug — use the workspace's store slug so the service's digest writer can find the repo |
103
103
  | `ads` / `ads list` | List the workspace's brand instances and their provisioning status |
104
104
  | `ads new <name> [--objective] [--budget] [--country] [--link]` | Scaffold a campaign FOLDER — the drag-and-drop form: `campaign.yaml` for budget/audience/copy defaults, `creatives/` for the files that become ads. Three questions when interactive, flags otherwise. `cp -r` an existing folder to duplicate a campaign |
105
+ | `ads pull [--account <act_id>] [--days <n>] [--force]` | The existing account as files: every ingested campaign lands as `campaigns/<slug>.yaml` (imported form — names in `label:`, budgets, statuses, core audience) plus `campaigns/.bindings.json` mapping YAML nodes to platform ids. A hash ledger protects local edits: unedited files fast-forward, edited ones are kept and named, deletions never happen automatically |
105
106
  | `ads remove <slug>` | Deprecate a brand instance — soft-deletes the record (drops out of `ads list` and the service's repo-token minting; survives for history). The GitHub repo is deliberately untouched: deleting real campaign intent is a human-on-GitHub decision |
106
107
  | `ads status` | The instance at a glance: brand + consent, platform access and credential expiry, ingestion freshness (with per-account failures), first-party join coverage, plan and pending-proposal counts. Exits non-zero when the service reports problems |
107
108
  | `ads report [--date <YYYY-MM-DD>] [--platform <all\|meta\|google>]` | The daily report in the terminal — totals vs the day before, where the money moved, what changed in the account |
package/dist/cli.js CHANGED
@@ -18234,7 +18234,7 @@ function updateNotifier(options) {
18234
18234
 
18235
18235
  // src/cli.ts
18236
18236
  import { createRequire as createRequire2 } from "node:module";
18237
- import { dirname as dirname2, join as join11 } from "node:path";
18237
+ import { dirname as dirname3, join as join12 } from "node:path";
18238
18238
  import { fileURLToPath as fileURLToPath3 } from "node:url";
18239
18239
 
18240
18240
  // src/auth.ts
@@ -20338,10 +20338,105 @@ async function adsList() {
20338
20338
  }
20339
20339
  }
20340
20340
 
20341
+ // src/ads-pull.ts
20342
+ import { createHash as createHash2 } from "node:crypto";
20343
+ import {
20344
+ existsSync as existsSync9,
20345
+ mkdirSync as mkdirSync4,
20346
+ readFileSync as readFileSync7,
20347
+ writeFileSync as writeFileSync8
20348
+ } from "node:fs";
20349
+ import { dirname as dirname2, join as join9, resolve as resolve7 } from "node:path";
20350
+ var sha = (s) => createHash2("sha256").update(s).digest("hex");
20351
+ function checkoutRoot() {
20352
+ const cwd = resolve7(".");
20353
+ if (existsSync9(join9(cwd, ".zalify", "brand-repo.json")) || existsSync9(join9(cwd, "campaigns"))) {
20354
+ return cwd;
20355
+ }
20356
+ throw new Error("Not inside a brand-instance checkout — run from the repo `zalify ads create` made.");
20357
+ }
20358
+ function readBindings(root) {
20359
+ const path9 = join9(root, "campaigns", ".bindings.json");
20360
+ if (!existsSync9(path9))
20361
+ return { nodes: {}, pulled: {} };
20362
+ const raw = JSON.parse(readFileSync7(path9, "utf8"));
20363
+ return { nodes: raw.nodes ?? {}, pulled: raw.pulled ?? {} };
20364
+ }
20365
+ async function adsPull(opts) {
20366
+ const auth = requireActive();
20367
+ const root = checkoutRoot();
20368
+ const params = new URLSearchParams;
20369
+ if (opts.account)
20370
+ params.set("account", opts.account);
20371
+ if (opts.days)
20372
+ params.set("days", opts.days);
20373
+ const q = params.toString();
20374
+ const res = await fetch(`${auth.appUrl}/api/autopilot/ads-code/export${q ? `?${q}` : ""}`, {
20375
+ headers: {
20376
+ Authorization: `Bearer ${auth.key}`,
20377
+ "x-zalify-workspace-id": auth.workspaceId
20378
+ }
20379
+ });
20380
+ if (!res.ok) {
20381
+ const body = await res.json().catch(() => ({}));
20382
+ throw new Error(`export failed (${res.status}): ${body.error ?? ""}`);
20383
+ }
20384
+ const result = await res.json();
20385
+ const bindings = readBindings(root);
20386
+ let written = 0;
20387
+ let unchanged = 0;
20388
+ const skipped = [];
20389
+ for (const f of result.files) {
20390
+ const abs = join9(root, f.path);
20391
+ const nextHash = sha(f.yaml);
20392
+ const lastPulled = bindings.pulled[f.path];
20393
+ if (existsSync9(abs)) {
20394
+ const current = readFileSync7(abs, "utf8");
20395
+ if (sha(current) === nextHash) {
20396
+ unchanged += 1;
20397
+ bindings.pulled[f.path] = nextHash;
20398
+ continue;
20399
+ }
20400
+ const locallyEdited = lastPulled !== undefined && sha(current) !== lastPulled;
20401
+ if (locallyEdited && !opts.force) {
20402
+ skipped.push(f.path);
20403
+ continue;
20404
+ }
20405
+ }
20406
+ mkdirSync4(dirname2(abs), { recursive: true });
20407
+ writeFileSync8(abs, f.yaml);
20408
+ bindings.pulled[f.path] = nextHash;
20409
+ written += 1;
20410
+ }
20411
+ Object.assign(bindings.nodes, result.bindings);
20412
+ const bindingsPath = join9(root, "campaigns", ".bindings.json");
20413
+ mkdirSync4(dirname2(bindingsPath), { recursive: true });
20414
+ writeFileSync8(bindingsPath, `${JSON.stringify(bindings, null, 2)}
20415
+ `);
20416
+ const exported = new Set(result.files.map((f) => f.path));
20417
+ const gone = Object.keys(bindings.pulled).filter((p) => !exported.has(p) && existsSync9(join9(root, p)));
20418
+ const s = result.summary;
20419
+ console.log(`pulled ${s.campaigns} campaigns (${s.adSets} ad sets, ${s.ads} ads): ` + `${written} written, ${unchanged} unchanged, ${skipped.length} kept (locally edited)`);
20420
+ for (const p of skipped)
20421
+ console.log(` kept ${p} (edited — --force overwrites)`);
20422
+ if (gone.length > 0) {
20423
+ console.log(`${gone.length} file${gone.length > 1 ? "s" : ""} no longer upstream (left in place):`);
20424
+ for (const p of gone.slice(0, 10))
20425
+ console.log(` gone ${p}`);
20426
+ if (gone.length > 10)
20427
+ console.log(` … and ${gone.length - 10} more`);
20428
+ }
20429
+ if (s.invalid.length > 0) {
20430
+ console.log(`${s.invalid.length} campaigns failed export-side validation — report this; they were not written.`);
20431
+ }
20432
+ console.log(`
20433
+ Review with git diff; commit when it reads right.`);
20434
+ }
20435
+
20341
20436
  // src/ads-read.ts
20342
20437
  import { spawn as spawn4 } from "node:child_process";
20343
- import { existsSync as existsSync9, readFileSync as readFileSync7 } from "node:fs";
20344
- import { join as join9, resolve as resolve7 } from "node:path";
20438
+ import { existsSync as existsSync10, readFileSync as readFileSync8 } from "node:fs";
20439
+ import { join as join10, resolve as resolve8 } from "node:path";
20345
20440
 
20346
20441
  // node_modules/yaml/dist/index.js
20347
20442
  var composer = require_composer();
@@ -20477,12 +20572,12 @@ account changes: not tracked for this platform yet` : `
20477
20572
  account changes: ${a.byPerson} by people, ${a.byMeta} automated`);
20478
20573
  }
20479
20574
  function adsDigest(dir2) {
20480
- const root = resolve7(dir2 ?? ".");
20481
- const path9 = join9(root, "signals", "latest.json");
20482
- if (!existsSync9(path9)) {
20575
+ const root = resolve8(dir2 ?? ".");
20576
+ const path9 = join10(root, "signals", "latest.json");
20577
+ if (!existsSync10(path9)) {
20483
20578
  throw new Error("signals/latest.json not found — run from a brand-instance checkout " + "(and pull: the service commits a digest daily).");
20484
20579
  }
20485
- const d = JSON.parse(readFileSync7(path9, "utf8"));
20580
+ const d = JSON.parse(readFileSync8(path9, "utf8"));
20486
20581
  console.log(`digest of ${d.window.days} days (${d.window.first_date ?? "—"} … ${d.window.last_date ?? "—"}), generated ${when(d.generated_at)}`);
20487
20582
  const t = d.totals;
20488
20583
  console.log(` spend ${money(t.spend)} purchases ${num(t.purchases)} roas ${t.platform_roas ?? "—"}`);
@@ -20521,26 +20616,26 @@ function adsOpen() {
20521
20616
  }
20522
20617
  async function adsDoctor(dir2) {
20523
20618
  const auth = requireActive();
20524
- const root = resolve7(dir2 ?? ".");
20619
+ const root = resolve8(dir2 ?? ".");
20525
20620
  let failed = 0;
20526
20621
  const ok = (msg) => console.log(` ✓ ${msg}`);
20527
20622
  const bad = (msg) => {
20528
20623
  console.log(` ✗ ${msg}`);
20529
20624
  failed += 1;
20530
20625
  };
20531
- const linkPath = join9(root, ".zalify", "brand-repo.json");
20626
+ const linkPath = join10(root, ".zalify", "brand-repo.json");
20532
20627
  let link = null;
20533
- if (existsSync9(linkPath)) {
20534
- link = JSON.parse(readFileSync7(linkPath, "utf8"));
20628
+ if (existsSync10(linkPath)) {
20629
+ link = JSON.parse(readFileSync8(linkPath, "utf8"));
20535
20630
  ok(`checkout linked to ${link?.repo} (slug ${link?.slug})`);
20536
20631
  } else {
20537
20632
  console.log(" - not inside a brand-instance checkout (repo checks only)");
20538
20633
  }
20539
20634
  let identity2 = null;
20540
- const identityPath = join9(root, "identity.yaml");
20541
- if (existsSync9(identityPath)) {
20635
+ const identityPath = join10(root, "identity.yaml");
20636
+ if (existsSync10(identityPath)) {
20542
20637
  try {
20543
- identity2 = $parse(readFileSync7(identityPath, "utf8"));
20638
+ identity2 = $parse(readFileSync8(identityPath, "utf8"));
20544
20639
  ok("identity.yaml parses");
20545
20640
  if (identity2?.workspace_id && identity2.workspace_id !== auth.workspaceId) {
20546
20641
  bad(`identity.yaml workspace_id (${identity2.workspace_id}) ≠ active workspace (${auth.workspaceId}) — is the CLI on the right workspace?`);
@@ -20606,8 +20701,8 @@ ${failed} problem${failed > 1 ? "s" : ""}.`);
20606
20701
  }
20607
20702
 
20608
20703
  // src/brand.ts
20609
- import { existsSync as existsSync10, mkdirSync as mkdirSync4, readdirSync as readdirSync3, readFileSync as readFileSync8, writeFileSync as writeFileSync8 } from "node:fs";
20610
- import { basename as basename5, join as join10, resolve as resolve8 } from "node:path";
20704
+ import { existsSync as existsSync11, mkdirSync as mkdirSync5, readdirSync as readdirSync3, readFileSync as readFileSync9, writeFileSync as writeFileSync9 } from "node:fs";
20705
+ import { basename as basename5, join as join11, resolve as resolve9 } from "node:path";
20611
20706
  var AUTHORING_MD = `# Brand authoring guide
20612
20707
 
20613
20708
  The rules for filling in this folder. An author — human or AI agent —
@@ -20908,19 +21003,19 @@ var MANIFEST_JSON = JSON.stringify({
20908
21003
  }, null, 2) + `
20909
21004
  `;
20910
21005
  function brandInit(dir2 = ".", opts = {}) {
20911
- const target = resolve8(dir2);
21006
+ const target = resolve9(dir2);
20912
21007
  const slug = basename5(target).toLowerCase().replaceAll(/[^a-z0-9-]+/g, "-").replaceAll(/^-+|-+$/g, "");
20913
- if (existsSync10(target) && readdirSync3(target).length > 0) {
21008
+ if (existsSync11(target) && readdirSync3(target).length > 0) {
20914
21009
  throw new Error(`${dir2} already exists and is not empty`);
20915
21010
  }
20916
21011
  const name = opts.name ?? slug.split("-").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
20917
21012
  const currency = (opts.currency ?? "USD").toUpperCase();
20918
- mkdirSync4(join10(target, "images"), { recursive: true });
20919
- writeFileSync8(join10(target, "AUTHORING.md"), AUTHORING_MD);
20920
- writeFileSync8(join10(target, "brand.md"), BRAND_MD(name));
20921
- writeFileSync8(join10(target, "store.json"), STORE_JSON(name, slug, opts.vertical ?? "<vertical>", currency));
20922
- writeFileSync8(join10(target, "catalog.json"), CATALOG_JSON(name, currency));
20923
- writeFileSync8(join10(target, "images", "manifest.json"), MANIFEST_JSON);
21013
+ mkdirSync5(join11(target, "images"), { recursive: true });
21014
+ writeFileSync9(join11(target, "AUTHORING.md"), AUTHORING_MD);
21015
+ writeFileSync9(join11(target, "brand.md"), BRAND_MD(name));
21016
+ writeFileSync9(join11(target, "store.json"), STORE_JSON(name, slug, opts.vertical ?? "<vertical>", currency));
21017
+ writeFileSync9(join11(target, "catalog.json"), CATALOG_JSON(name, currency));
21018
+ writeFileSync9(join11(target, "images", "manifest.json"), MANIFEST_JSON);
20924
21019
  console.log(`Scaffolded brand data in ${dir2}:`);
20925
21020
  for (const f of ["AUTHORING.md", "brand.md", "store.json", "catalog.json", "images/manifest.json"]) {
20926
21021
  console.log(` + ${f}`);
@@ -20931,23 +21026,23 @@ Author in this order: brand.md → catalog.json → images/manifest.json` + `
20931
21026
  }
20932
21027
  var MANIFEST_SIZES = new Set(["1024x1024", "1536x1024", "1024x1536"]);
20933
21028
  function brandValidate(dir2 = ".") {
20934
- const target = resolve8(dir2);
21029
+ const target = resolve9(dir2);
20935
21030
  const problems = [];
20936
21031
  const warnings = [];
20937
21032
  const readJson2 = (rel) => {
20938
- const path9 = join10(target, rel);
20939
- if (!existsSync10(path9)) {
21033
+ const path9 = join11(target, rel);
21034
+ if (!existsSync11(path9)) {
20940
21035
  problems.push(`missing ${rel}`);
20941
21036
  return null;
20942
21037
  }
20943
21038
  try {
20944
- return JSON.parse(readFileSync8(path9, "utf8"));
21039
+ return JSON.parse(readFileSync9(path9, "utf8"));
20945
21040
  } catch (err) {
20946
21041
  problems.push(`${rel}: invalid JSON — ${err instanceof Error ? err.message : err}`);
20947
21042
  return null;
20948
21043
  }
20949
21044
  };
20950
- if (!existsSync10(join10(target, "brand.md")))
21045
+ if (!existsSync11(join11(target, "brand.md")))
20951
21046
  problems.push("missing brand.md");
20952
21047
  const store = readJson2("store.json");
20953
21048
  if (store && (!store.name || !store.slug))
@@ -21121,9 +21216,9 @@ function brandValidate(dir2 = ".") {
21121
21216
  }
21122
21217
 
21123
21218
  // src/cli.ts
21124
- var __dirname4 = dirname2(fileURLToPath3(import.meta.url));
21219
+ var __dirname4 = dirname3(fileURLToPath3(import.meta.url));
21125
21220
  var require2 = createRequire2(import.meta.url);
21126
- var pkg = require2(join11(__dirname4, "..", "package.json"));
21221
+ var pkg = require2(join12(__dirname4, "..", "package.json"));
21127
21222
  try {
21128
21223
  updateNotifier({ pkg }).notify({
21129
21224
  isGlobal: true,
@@ -21208,6 +21303,9 @@ ads.command("remove <slug>").description("Deprecate a brand instance (record sof
21208
21303
  ads.command("new <name>").description("Scaffold a campaign folder — drop images into creatives/, then sync").option("--objective <o>", "sales | leads | traffic | awareness").option("--budget <b>", '"$60/day" or "$900 total"').option("--country <cc>", "comma-separated ISO codes, e.g. US,CA").option("--link <url>", "destination URL for creative_defaults").action(async (name, options) => {
21209
21304
  await adsNew(name, options);
21210
21305
  });
21306
+ ads.command("pull").description("The existing account as files — campaigns/<slug>.yaml + bindings, from what the service has ingested").option("--account <act_id>", "one ad account only").option("--days <n>", "ads seen in the trailing window (default 30)").option("--force", "overwrite locally edited files").action(async (options) => {
21307
+ await adsPull(options);
21308
+ });
21211
21309
  ads.command("status").description("The brand instance at a glance: access, ingestion freshness, plans, pending proposals").action(async () => {
21212
21310
  await adsStatus();
21213
21311
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalify/cli",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "Zalify CLI - command-line interface for Zalify",
5
5
  "type": "module",
6
6
  "main": "dist/cli.js",