@zalify/cli 0.22.1 → 0.24.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 +4 -3
  2. package/dist/cli.js +270 -104
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -101,9 +101,10 @@ its new home under `shop`.
101
101
  | --- | --- |
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
- | `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 |
106
- | `ads diff` | Desired (your campaign files) vs actual (the ingested account), keyed through bindings: bound nodes compare budget/status, unbound nodes become creations. Read-only |
104
+ | `ads new <name> [--account <folder>] [--objective] [--budget] [--country] [--link]` | Scaffold a campaign FOLDER at `accounts/<account>/<name>/` (account inferred when only one exists): `campaign.yaml` for budget/audience/copy defaults, `creatives/` for the files that become ads. `cp -r` an existing folder to duplicate a campaign |
105
+ | `ads pull [--account <act_id>] [--days <n>] [--force]` | The existing accounts as a tree: every ingested campaign lands as `accounts/<account-name>/<campaign>/campaign.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 |
106
+ | `ads creative generate <campaign> --prompt <text> [--orientation] [--ref <url...>] [--name]` | One image through the canvas pipeline (safe zones, product fidelity with refs), downloaded INTO the campaign's `creatives/` and recorded in its `assets.json` — the filesystem stays the interface, and the asset is stored once |
107
+ | `ads diff` | Desired (your campaign files) vs actual (the ingested account), keyed through bindings: bound nodes compare budget/status, unbound nodes become creations. Folder campaigns upload their `creatives/` first (presign + checksum dedup — the `brand images push` machinery) and derive server-side. Read-only against the platform |
107
108
  | `ads apply [-y] [--activate]` | Execute the diff after showing it and asking. Creations land **PAUSED** — always; budget edits apply to bound entities; `paused→active` is refused without `--activate` (turning spend on is said twice; `active→paused` always executes). Created ids merge into the bindings ledger |
108
109
  | `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 |
109
110
  | `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 |
package/dist/cli.js CHANGED
@@ -31,6 +31,20 @@ var __toESM = (mod, isNodeMode, target) => {
31
31
  return to;
32
32
  };
33
33
  var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
34
+ var __returnValue = (v) => v;
35
+ function __exportSetter(name, newValue) {
36
+ this[name] = __returnValue.bind(null, newValue);
37
+ }
38
+ var __export = (target, all) => {
39
+ for (var name in all)
40
+ __defProp(target, name, {
41
+ get: all[name],
42
+ enumerable: true,
43
+ configurable: true,
44
+ set: __exportSetter.bind(all, name)
45
+ });
46
+ };
47
+ var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
34
48
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
35
49
 
36
50
  // node_modules/commander/lib/error.js
@@ -7929,6 +7943,17 @@ var require_ansi_align = __commonJS((exports, module) => {
7929
7943
  }
7930
7944
  });
7931
7945
 
7946
+ // src/hash.ts
7947
+ var exports_hash = {};
7948
+ __export(exports_hash, {
7949
+ sha256: () => sha256
7950
+ });
7951
+ import { createHash } from "node:crypto";
7952
+ function sha256(bytes) {
7953
+ return createHash("sha256").update(bytes).digest("hex");
7954
+ }
7955
+ var init_hash = () => {};
7956
+
7932
7957
  // node_modules/yaml/dist/nodes/identity.js
7933
7958
  var require_identity = __commonJS((exports) => {
7934
7959
  var ALIAS = Symbol.for("yaml.alias");
@@ -18234,7 +18259,7 @@ function updateNotifier(options) {
18234
18259
 
18235
18260
  // src/cli.ts
18236
18261
  import { createRequire as createRequire2 } from "node:module";
18237
- import { dirname as dirname3, join as join13 } from "node:path";
18262
+ import { dirname as dirname3, join as join14 } from "node:path";
18238
18263
  import { fileURLToPath as fileURLToPath3 } from "node:url";
18239
18264
 
18240
18265
  // src/auth.ts
@@ -18555,13 +18580,8 @@ async function whoami() {
18555
18580
  console.log(live ? "status ✓ key verified" : `status ! not verified — ${liveError}`);
18556
18581
  }
18557
18582
 
18558
- // src/hash.ts
18559
- import { createHash } from "node:crypto";
18560
- function sha256(bytes) {
18561
- return createHash("sha256").update(bytes).digest("hex");
18562
- }
18563
-
18564
18583
  // src/assets.ts
18584
+ init_hash();
18565
18585
  import { readFileSync as readFileSync2, writeFileSync as writeFileSync3, existsSync as existsSync2, readdirSync } from "node:fs";
18566
18586
  import { join as join2, resolve } from "node:path";
18567
18587
  var BATCH = 10;
@@ -18601,8 +18621,11 @@ async function assetsPull(storeDir) {
18601
18621
  console.log(`Done: ${pulled} downloaded, ${Object.keys(index).length - pulled} already local.`);
18602
18622
  }
18603
18623
  async function assetsPush(storeDir) {
18624
+ await pushImagesIn(imagesDirFor(storeDir));
18625
+ }
18626
+ async function pushImagesIn(dir2) {
18604
18627
  const { appUrl, workspaceId, key, workspaceName, workspaceSlug } = requireActive();
18605
- const imagesDir = imagesDirFor(storeDir);
18628
+ const imagesDir = dir2;
18606
18629
  const indexPath = join2(imagesDir, "assets.json");
18607
18630
  const index = readIndex(imagesDir);
18608
18631
  async function api(path9, body) {
@@ -18625,13 +18648,13 @@ async function assetsPush(storeDir) {
18625
18648
  }
18626
18649
  return json;
18627
18650
  }
18628
- const files = readdirSync(imagesDir).filter((f) => f.endsWith(".png")).map((name) => {
18651
+ const files = readdirSync(imagesDir).filter((f) => /\.(png|jpe?g|webp)$/i.test(f)).map((name) => {
18629
18652
  const bytes = readFileSync2(join2(imagesDir, name));
18630
18653
  return { name, bytes, checksum: sha256(bytes) };
18631
18654
  }).filter((f) => index[f.name]?.checksum !== f.checksum);
18632
18655
  if (!files.length) {
18633
18656
  console.log("Everything already pushed.");
18634
- return;
18657
+ return index;
18635
18658
  }
18636
18659
  console.log(`Pushing ${files.length} file(s) to "${workspaceName || workspaceId}"`);
18637
18660
  for (let i = 0;i < files.length; i += BATCH) {
@@ -18699,6 +18722,7 @@ async function assetsPush(storeDir) {
18699
18722
  `);
18700
18723
  }
18701
18724
  console.log("Done.");
18725
+ return index;
18702
18726
  }
18703
18727
 
18704
18728
  // src/self-update.ts
@@ -18894,6 +18918,7 @@ async function workspaceSet(slugOrId) {
18894
18918
  }
18895
18919
 
18896
18920
  // src/images.ts
18921
+ init_hash();
18897
18922
  import { readFileSync as readFileSync3, writeFileSync as writeFileSync4, existsSync as existsSync4, rmSync as rmSync2 } from "node:fs";
18898
18923
  import { join as join4, resolve as resolve2 } from "node:path";
18899
18924
  var MAX_JOBS_PER_REQUEST = 40;
@@ -19111,6 +19136,7 @@ import { existsSync as existsSync6, readFileSync as readFileSync5, writeFileSync
19111
19136
  import { basename as basename2, join as join6, resolve as resolve4 } from "node:path";
19112
19137
 
19113
19138
  // src/theme.ts
19139
+ init_hash();
19114
19140
  import { spawnSync as spawnSync2 } from "node:child_process";
19115
19141
  import { randomBytes as randomBytes2 } from "node:crypto";
19116
19142
  import {
@@ -20118,8 +20144,8 @@ async function shopifyUploadImages(storeDir, options = {}) {
20118
20144
 
20119
20145
  // src/ads.ts
20120
20146
  import { spawnSync as spawnSync4 } from "node:child_process";
20121
- import { existsSync as existsSync8, mkdirSync as mkdirSync3, writeFileSync as writeFileSync7 } from "node:fs";
20122
- import { basename as basename4, join as join8, resolve as resolve6 } from "node:path";
20147
+ import { existsSync as existsSync8, mkdirSync as mkdirSync3, readdirSync as readdirSync3, writeFileSync as writeFileSync7 } from "node:fs";
20148
+ import { basename as basename4, join as join8, relative, resolve as resolve6 } from "node:path";
20123
20149
  import { createInterface } from "node:readline/promises";
20124
20150
  function apiError3(status, json) {
20125
20151
  if (json.code === "SLUG_TAKEN") {
@@ -20257,14 +20283,22 @@ Next steps:
20257
20283
  ` + ` - the Autopilot service picks the instance up from there`);
20258
20284
  }
20259
20285
  var CAMPAIGN_NAME_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
20260
- function campaignsDir() {
20286
+ function campaignParent(account) {
20261
20287
  const cwd = resolve6(".");
20262
- if (existsSync8(join8(cwd, "campaigns")))
20263
- return join8(cwd, "campaigns");
20264
- if (existsSync8(join8(cwd, ".zalify", "brand-repo.json"))) {
20265
- return join8(cwd, "campaigns");
20266
- }
20267
- throw new Error("Not inside a brand-instance checkout — run from the repo `zalify ads create` made.");
20288
+ const inCheckout = existsSync8(join8(cwd, ".zalify", "brand-repo.json")) || existsSync8(join8(cwd, "accounts"));
20289
+ if (!inCheckout) {
20290
+ throw new Error("Not inside a brand-instance checkout — run from the repo `zalify ads create` made.");
20291
+ }
20292
+ const accountsDir = join8(cwd, "accounts");
20293
+ if (account)
20294
+ return join8(accountsDir, account);
20295
+ const dirs = existsSync8(accountsDir) ? readdirSync3(accountsDir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name) : [];
20296
+ if (dirs.length === 1)
20297
+ return join8(accountsDir, dirs[0]);
20298
+ if (dirs.length === 0) {
20299
+ throw new Error("No account folders yet — `zalify ads pull` once, or pass --account <folder>.");
20300
+ }
20301
+ throw new Error(`Several accounts (${dirs.join(", ")}) — pass --account <folder>.`);
20268
20302
  }
20269
20303
  async function ask(question, fallback) {
20270
20304
  if (!process.stdin.isTTY)
@@ -20278,9 +20312,10 @@ async function adsNew(name, opts) {
20278
20312
  if (!CAMPAIGN_NAME_RE.test(name) || name.length > 63) {
20279
20313
  throw new Error(`"${name}" — campaign names are lowercase letters, digits, and inner hyphens.`);
20280
20314
  }
20281
- const dir2 = join8(campaignsDir(), name);
20315
+ const parent = campaignParent(opts.account);
20316
+ const dir2 = join8(parent, name);
20282
20317
  if (existsSync8(dir2))
20283
- throw new Error(`campaigns/${name}/ already exists.`);
20318
+ throw new Error(`${dir2} already exists.`);
20284
20319
  const objective = opts.objective ?? await ask("objective (sales/leads/traffic/awareness)", "sales");
20285
20320
  const budget = opts.budget ?? await ask("budget", "$20/day");
20286
20321
  const country = opts.country ?? await ask("country", "US");
@@ -20307,7 +20342,7 @@ ad_sets:
20307
20342
  age: 18-65
20308
20343
  advantage: true
20309
20344
  `);
20310
- console.log(`✓ campaigns/${name}/ is ready`);
20345
+ console.log(`✓ ${relative(resolve6("."), dir2)}/ is ready`);
20311
20346
  console.log(" 1. drag images into creatives/");
20312
20347
  console.log(" 2. fill primary_text and link in campaign.yaml");
20313
20348
  console.log(" 3. `zalify ads sync` when it lands — validate, diff, apply");
@@ -20339,8 +20374,14 @@ async function adsList() {
20339
20374
  }
20340
20375
 
20341
20376
  // src/ads-diff.ts
20342
- import { existsSync as existsSync9, readdirSync as readdirSync3, readFileSync as readFileSync7, writeFileSync as writeFileSync8 } from "node:fs";
20343
- import { join as join9, resolve as resolve7 } from "node:path";
20377
+ import {
20378
+ existsSync as existsSync10,
20379
+ mkdirSync as mkdirSync4,
20380
+ readdirSync as readdirSync5,
20381
+ readFileSync as readFileSync8,
20382
+ writeFileSync as writeFileSync9
20383
+ } from "node:fs";
20384
+ import { join as join10, resolve as resolve7 } from "node:path";
20344
20385
  import { createInterface as createInterface2 } from "node:readline/promises";
20345
20386
 
20346
20387
  // node_modules/yaml/dist/index.js
@@ -20389,44 +20430,159 @@ var $stringify = publicApi.stringify;
20389
20430
  var $visit = visit.visit;
20390
20431
  var $visitAsync = visit.visitAsync;
20391
20432
 
20433
+ // src/ads-sync.ts
20434
+ import { existsSync as existsSync9, readdirSync as readdirSync4, readFileSync as readFileSync7, writeFileSync as writeFileSync8 } from "node:fs";
20435
+ import { join as join9 } from "node:path";
20436
+ var CREATIVE_EXT = /\.(png|jpe?g|webp)$/i;
20437
+ function campaignDirs(base) {
20438
+ const found = [];
20439
+ const accounts = join9(base, "accounts");
20440
+ if (existsSync9(accounts)) {
20441
+ for (const acct of readdirSync4(accounts, { withFileTypes: true })) {
20442
+ if (!acct.isDirectory())
20443
+ continue;
20444
+ const acctDir = join9(accounts, acct.name);
20445
+ for (const c of readdirSync4(acctDir, { withFileTypes: true })) {
20446
+ if (c.isDirectory() && existsSync9(join9(acctDir, c.name, "campaign.yaml"))) {
20447
+ found.push(`accounts/${acct.name}/${c.name}`);
20448
+ }
20449
+ }
20450
+ }
20451
+ }
20452
+ const legacy = join9(base, "campaigns");
20453
+ if (existsSync9(legacy)) {
20454
+ for (const e of readdirSync4(legacy, { withFileTypes: true })) {
20455
+ if (e.isDirectory() && existsSync9(join9(legacy, e.name, "campaign.yaml"))) {
20456
+ found.push(`campaigns/${e.name}`);
20457
+ }
20458
+ }
20459
+ }
20460
+ return found;
20461
+ }
20462
+ async function collectFolder(base, rel) {
20463
+ const dir2 = join9(base, rel);
20464
+ const doc = $parse(readFileSync7(join9(dir2, "campaign.yaml"), "utf8"));
20465
+ const creativesDir = join9(dir2, "creatives");
20466
+ const sources = [];
20467
+ const gather = async (sub, adSet) => {
20468
+ const abs = join9(creativesDir, sub);
20469
+ if (!existsSync9(abs))
20470
+ return;
20471
+ const entries = readdirSync4(abs, { withFileTypes: true });
20472
+ const hasImages = entries.some((e) => e.isFile() && CREATIVE_EXT.test(e.name));
20473
+ const index = hasImages ? await pushImagesIn(abs) : {};
20474
+ for (const e of entries) {
20475
+ if (e.isDirectory() && sub === "") {
20476
+ await gather(e.name, e.name);
20477
+ continue;
20478
+ }
20479
+ if (!e.isFile() || !CREATIVE_EXT.test(e.name))
20480
+ continue;
20481
+ const sidecarPath = join9(abs, `${e.name.replace(CREATIVE_EXT, "")}.copy.yaml`);
20482
+ sources.push({
20483
+ filename: e.name,
20484
+ adSet,
20485
+ ...existsSync9(sidecarPath) ? { sidecar: $parse(readFileSync7(sidecarPath, "utf8")) } : {},
20486
+ ...index[e.name] ? { asset: index[e.name].url } : {}
20487
+ });
20488
+ }
20489
+ };
20490
+ await gather("", "");
20491
+ return { path: `${rel}/campaign.yaml`, doc, sources };
20492
+ }
20493
+ async function adsCreativeGenerate(opts) {
20494
+ const auth = requireActive();
20495
+ const matches = campaignDirs(".").filter((rel) => rel.split("/").pop() === opts.campaign);
20496
+ if (matches.length === 0) {
20497
+ throw new Error(`No campaign folder "${opts.campaign}" — \`zalify ads new ${opts.campaign}\` first.`);
20498
+ }
20499
+ if (matches.length > 1) {
20500
+ throw new Error(`"${opts.campaign}" exists under several accounts: ${matches.join(", ")} — cd closer or rename.`);
20501
+ }
20502
+ const dir2 = join9(".", matches[0], "creatives");
20503
+ if (!existsSync9(dir2)) {
20504
+ throw new Error(`${matches[0]}/creatives/ not found.`);
20505
+ }
20506
+ console.log("Generating (the canvas pipeline: safe zones + product fidelity)…");
20507
+ const res = await fetch(`${auth.appUrl}/api/autopilot/ads-code/creative`, {
20508
+ method: "POST",
20509
+ headers: {
20510
+ Authorization: `Bearer ${auth.key}`,
20511
+ "x-zalify-workspace-id": auth.workspaceId,
20512
+ "Content-Type": "application/json"
20513
+ },
20514
+ body: JSON.stringify({
20515
+ prompt: opts.prompt,
20516
+ orientation: opts.orientation ?? "square",
20517
+ refUrls: opts.ref ?? []
20518
+ })
20519
+ });
20520
+ const json = await res.json().catch(() => ({}));
20521
+ if (!res.ok || !json.image) {
20522
+ throw new Error(`generation failed (${res.status}): ${json.error ?? ""}`);
20523
+ }
20524
+ const download = await fetch(json.image.url);
20525
+ if (!download.ok)
20526
+ throw new Error(`download failed (${download.status})`);
20527
+ const bytes = Buffer.from(await download.arrayBuffer());
20528
+ const filename = `${(opts.name ?? json.image.name ?? "generated").toLowerCase().replaceAll(/[^a-z0-9]+/g, "-").replaceAll(/^-+|-+$/g, "").slice(0, 60)}.png`;
20529
+ writeFileSync8(join9(dir2, filename), bytes);
20530
+ const indexPath = join9(dir2, "assets.json");
20531
+ const index = existsSync9(indexPath) ? JSON.parse(readFileSync7(indexPath, "utf8")) : {};
20532
+ const { sha256: sha2562 } = await Promise.resolve().then(() => (init_hash(), exports_hash));
20533
+ index[filename] = {
20534
+ assetId: json.image.assetId,
20535
+ url: json.image.url,
20536
+ checksum: sha2562(bytes)
20537
+ };
20538
+ writeFileSync8(indexPath, `${JSON.stringify(index, null, 2)}
20539
+ `);
20540
+ console.log(`✓ creatives/${filename} (asset ${json.image.assetId})`);
20541
+ console.log(" Review the image; delete the file to reject it. `zalify ads diff` when ready.");
20542
+ }
20543
+
20392
20544
  // src/ads-diff.ts
20393
20545
  function root() {
20394
20546
  const cwd = resolve7(".");
20395
- if (existsSync9(join9(cwd, ".zalify", "brand-repo.json")) || existsSync9(join9(cwd, "campaigns"))) {
20547
+ if (existsSync10(join10(cwd, ".zalify", "brand-repo.json")) || existsSync10(join10(cwd, "campaigns"))) {
20396
20548
  return cwd;
20397
20549
  }
20398
20550
  throw new Error("Not inside a brand-instance checkout — run from the repo `zalify ads create` made.");
20399
20551
  }
20400
- function collect(base) {
20401
- const dir2 = join9(base, "campaigns");
20402
- if (!existsSync9(dir2))
20403
- return { files: [], folders: 0 };
20552
+ async function collect(base) {
20404
20553
  const files = [];
20405
- let folders = 0;
20406
- for (const entry of readdirSync3(dir2, { withFileTypes: true })) {
20407
- if (entry.isDirectory()) {
20408
- if (existsSync9(join9(dir2, entry.name, "campaign.yaml")))
20409
- folders += 1;
20410
- continue;
20411
- }
20412
- if (!entry.name.endsWith(".yaml") || entry.name.endsWith(".copy.yaml")) {
20413
- continue;
20414
- }
20415
- const path9 = `campaigns/${entry.name}`;
20416
- try {
20417
- files.push({ path: path9, doc: $parse(readFileSync7(join9(base, path9), "utf8")) });
20418
- } catch (err) {
20419
- throw new Error(`${path9} is not valid YAML: ${String(err).slice(0, 120)}`);
20554
+ const legacy = join10(base, "campaigns");
20555
+ if (existsSync10(legacy)) {
20556
+ for (const entry of readdirSync5(legacy, { withFileTypes: true })) {
20557
+ if (entry.isDirectory())
20558
+ continue;
20559
+ if (!entry.name.endsWith(".yaml") || entry.name.endsWith(".copy.yaml")) {
20560
+ continue;
20561
+ }
20562
+ const path9 = `campaigns/${entry.name}`;
20563
+ try {
20564
+ files.push({ path: path9, doc: $parse(readFileSync8(join10(base, path9), "utf8")) });
20565
+ } catch (err) {
20566
+ throw new Error(`${path9} is not valid YAML: ${String(err).slice(0, 120)}`);
20567
+ }
20420
20568
  }
20421
20569
  }
20422
- return { files, folders };
20570
+ for (const rel of campaignDirs(base)) {
20571
+ files.push(await collectFolder(base, rel));
20572
+ }
20573
+ return { files };
20574
+ }
20575
+ function ledgerPath(base) {
20576
+ return join10(base, "accounts", ".bindings.json");
20423
20577
  }
20424
20578
  function ledger(base) {
20425
- const path9 = join9(base, "campaigns", ".bindings.json");
20426
- if (!existsSync9(path9))
20427
- return { nodes: {}, pulled: {} };
20428
- const raw = JSON.parse(readFileSync7(path9, "utf8"));
20429
- return { nodes: raw.nodes ?? {}, pulled: raw.pulled ?? {} };
20579
+ for (const path9 of [ledgerPath(base), join10(base, "campaigns", ".bindings.json")]) {
20580
+ if (existsSync10(path9)) {
20581
+ const raw = JSON.parse(readFileSync8(path9, "utf8"));
20582
+ return { nodes: raw.nodes ?? {}, pulled: raw.pulled ?? {} };
20583
+ }
20584
+ }
20585
+ return { nodes: {}, pulled: {} };
20430
20586
  }
20431
20587
  async function post(auth, path9, payload) {
20432
20588
  const res = await fetch(`${auth.appUrl}/api/autopilot/${path9}`, {
@@ -20443,7 +20599,7 @@ async function post(auth, path9, payload) {
20443
20599
  throw new Error(`${path9}: ${res.status} ${json.error ?? ""}`.trim());
20444
20600
  return json;
20445
20601
  }
20446
- function renderPlan(result, folders) {
20602
+ function renderPlan(result) {
20447
20603
  for (const n of result.notes ?? []) {
20448
20604
  console.log(`· ${n.file} — ${n.message}`);
20449
20605
  }
@@ -20467,13 +20623,13 @@ function renderPlan(result, folders) {
20467
20623
  }
20468
20624
  const s = result.summary;
20469
20625
  console.log(`
20470
- ${s.creates} to create, ${s.updates} to update, ${s.unchanged} unchanged` + `${result.issues.length ? `, ${result.issues.length} files invalid` : ""}` + `${folders ? ` (${folders} folder campaigns skipped — sync is coming)` : ""}`);
20626
+ ${s.creates} to create, ${s.updates} to update, ${s.unchanged} unchanged` + `${result.issues.length ? `, ${result.issues.length} files invalid` : ""}`);
20471
20627
  }
20472
20628
  async function adsDiff() {
20473
20629
  const auth = requireActive();
20474
20630
  const base = root();
20475
- const { files, folders } = collect(base);
20476
- if (files.length === 0 && folders === 0) {
20631
+ const { files } = await collect(base);
20632
+ if (files.length === 0) {
20477
20633
  console.log("No campaign files — `zalify ads pull` or `zalify ads new` first.");
20478
20634
  return;
20479
20635
  }
@@ -20482,14 +20638,14 @@ async function adsDiff() {
20482
20638
  files,
20483
20639
  bindings: led.nodes
20484
20640
  });
20485
- renderPlan(result, folders);
20641
+ renderPlan(result);
20486
20642
  if (result.issues.length > 0)
20487
20643
  process.exitCode = 1;
20488
20644
  }
20489
20645
  async function adsApply(opts) {
20490
20646
  const auth = requireActive();
20491
20647
  const base = root();
20492
- const { files, folders } = collect(base);
20648
+ const { files } = await collect(base);
20493
20649
  if (files.length === 0) {
20494
20650
  console.log("No campaign files — `zalify ads pull` or `zalify ads new` first.");
20495
20651
  return;
@@ -20499,7 +20655,7 @@ async function adsApply(opts) {
20499
20655
  files,
20500
20656
  bindings: led.nodes
20501
20657
  });
20502
- renderPlan(diff, folders);
20658
+ renderPlan(diff);
20503
20659
  if (diff.issues.length > 0) {
20504
20660
  throw new Error("Fix the invalid files before applying.");
20505
20661
  }
@@ -20534,7 +20690,8 @@ ${result.refusedActivations} activation${result.refusedActivations > 1 ? "s" : "
20534
20690
  }
20535
20691
  if (Object.keys(result.newBindings).length > 0) {
20536
20692
  Object.assign(led.nodes, result.newBindings);
20537
- writeFileSync8(join9(base, "campaigns", ".bindings.json"), `${JSON.stringify(led, null, 2)}
20693
+ mkdirSync4(join10(base, "accounts"), { recursive: true });
20694
+ writeFileSync9(ledgerPath(base), `${JSON.stringify(led, null, 2)}
20538
20695
  `);
20539
20696
  console.log(`
20540
20697
  ${Object.keys(result.newBindings).length} new bindings recorded — everything created is PAUSED.`);
@@ -20546,26 +20703,31 @@ ${Object.keys(result.newBindings).length} new bindings recorded — everything c
20546
20703
  // src/ads-pull.ts
20547
20704
  import { createHash as createHash2 } from "node:crypto";
20548
20705
  import {
20549
- existsSync as existsSync10,
20550
- mkdirSync as mkdirSync4,
20551
- readFileSync as readFileSync8,
20552
- writeFileSync as writeFileSync9
20706
+ existsSync as existsSync11,
20707
+ mkdirSync as mkdirSync5,
20708
+ readFileSync as readFileSync9,
20709
+ writeFileSync as writeFileSync10
20553
20710
  } from "node:fs";
20554
- import { dirname as dirname2, join as join10, resolve as resolve8 } from "node:path";
20711
+ import { dirname as dirname2, join as join11, resolve as resolve8 } from "node:path";
20555
20712
  var sha = (s) => createHash2("sha256").update(s).digest("hex");
20556
20713
  function checkoutRoot() {
20557
20714
  const cwd = resolve8(".");
20558
- if (existsSync10(join10(cwd, ".zalify", "brand-repo.json")) || existsSync10(join10(cwd, "campaigns"))) {
20715
+ if (existsSync11(join11(cwd, ".zalify", "brand-repo.json")) || existsSync11(join11(cwd, "accounts")) || existsSync11(join11(cwd, "campaigns"))) {
20559
20716
  return cwd;
20560
20717
  }
20561
20718
  throw new Error("Not inside a brand-instance checkout — run from the repo `zalify ads create` made.");
20562
20719
  }
20563
20720
  function readBindings(root2) {
20564
- const path9 = join10(root2, "campaigns", ".bindings.json");
20565
- if (!existsSync10(path9))
20566
- return { nodes: {}, pulled: {} };
20567
- const raw = JSON.parse(readFileSync8(path9, "utf8"));
20568
- return { nodes: raw.nodes ?? {}, pulled: raw.pulled ?? {} };
20721
+ for (const path9 of [
20722
+ join11(root2, "accounts", ".bindings.json"),
20723
+ join11(root2, "campaigns", ".bindings.json")
20724
+ ]) {
20725
+ if (existsSync11(path9)) {
20726
+ const raw = JSON.parse(readFileSync9(path9, "utf8"));
20727
+ return { nodes: raw.nodes ?? {}, pulled: raw.pulled ?? {} };
20728
+ }
20729
+ }
20730
+ return { nodes: {}, pulled: {} };
20569
20731
  }
20570
20732
  async function adsPull(opts) {
20571
20733
  const auth = requireActive();
@@ -20592,11 +20754,11 @@ async function adsPull(opts) {
20592
20754
  let unchanged = 0;
20593
20755
  const skipped = [];
20594
20756
  for (const f of result.files) {
20595
- const abs = join10(root2, f.path);
20757
+ const abs = join11(root2, f.path);
20596
20758
  const nextHash = sha(f.yaml);
20597
20759
  const lastPulled = bindings.pulled[f.path];
20598
- if (existsSync10(abs)) {
20599
- const current = readFileSync8(abs, "utf8");
20760
+ if (existsSync11(abs)) {
20761
+ const current = readFileSync9(abs, "utf8");
20600
20762
  if (sha(current) === nextHash) {
20601
20763
  unchanged += 1;
20602
20764
  bindings.pulled[f.path] = nextHash;
@@ -20608,18 +20770,18 @@ async function adsPull(opts) {
20608
20770
  continue;
20609
20771
  }
20610
20772
  }
20611
- mkdirSync4(dirname2(abs), { recursive: true });
20612
- writeFileSync9(abs, f.yaml);
20773
+ mkdirSync5(dirname2(abs), { recursive: true });
20774
+ writeFileSync10(abs, f.yaml);
20613
20775
  bindings.pulled[f.path] = nextHash;
20614
20776
  written += 1;
20615
20777
  }
20616
20778
  Object.assign(bindings.nodes, result.bindings);
20617
- const bindingsPath = join10(root2, "campaigns", ".bindings.json");
20618
- mkdirSync4(dirname2(bindingsPath), { recursive: true });
20619
- writeFileSync9(bindingsPath, `${JSON.stringify(bindings, null, 2)}
20779
+ const bindingsPath = join11(root2, "accounts", ".bindings.json");
20780
+ mkdirSync5(dirname2(bindingsPath), { recursive: true });
20781
+ writeFileSync10(bindingsPath, `${JSON.stringify(bindings, null, 2)}
20620
20782
  `);
20621
20783
  const exported = new Set(result.files.map((f) => f.path));
20622
- const gone = Object.keys(bindings.pulled).filter((p) => !exported.has(p) && existsSync10(join10(root2, p)));
20784
+ const gone = Object.keys(bindings.pulled).filter((p) => !exported.has(p) && existsSync11(join11(root2, p)));
20623
20785
  const s = result.summary;
20624
20786
  console.log(`pulled ${s.campaigns} campaigns (${s.adSets} ad sets, ${s.ads} ads): ` + `${written} written, ${unchanged} unchanged, ${skipped.length} kept (locally edited)`);
20625
20787
  for (const p of skipped)
@@ -20640,8 +20802,8 @@ Review with git diff; commit when it reads right.`);
20640
20802
 
20641
20803
  // src/ads-read.ts
20642
20804
  import { spawn as spawn4 } from "node:child_process";
20643
- import { existsSync as existsSync11, readFileSync as readFileSync9 } from "node:fs";
20644
- import { join as join11, resolve as resolve9 } from "node:path";
20805
+ import { existsSync as existsSync12, readFileSync as readFileSync10 } from "node:fs";
20806
+ import { join as join12, resolve as resolve9 } from "node:path";
20645
20807
  async function serviceGet(auth, path9) {
20646
20808
  const res = await fetch(`${auth.appUrl}/api/autopilot/${path9}`, {
20647
20809
  headers: {
@@ -20730,11 +20892,11 @@ account changes: ${a.byPerson} by people, ${a.byMeta} automated`);
20730
20892
  }
20731
20893
  function adsDigest(dir2) {
20732
20894
  const root2 = resolve9(dir2 ?? ".");
20733
- const path9 = join11(root2, "signals", "latest.json");
20734
- if (!existsSync11(path9)) {
20895
+ const path9 = join12(root2, "signals", "latest.json");
20896
+ if (!existsSync12(path9)) {
20735
20897
  throw new Error("signals/latest.json not found — run from a brand-instance checkout " + "(and pull: the service commits a digest daily).");
20736
20898
  }
20737
- const d = JSON.parse(readFileSync9(path9, "utf8"));
20899
+ const d = JSON.parse(readFileSync10(path9, "utf8"));
20738
20900
  console.log(`digest of ${d.window.days} days (${d.window.first_date ?? "—"} … ${d.window.last_date ?? "—"}), generated ${when(d.generated_at)}`);
20739
20901
  const t = d.totals;
20740
20902
  console.log(` spend ${money(t.spend)} purchases ${num(t.purchases)} roas ${t.platform_roas ?? "—"}`);
@@ -20780,19 +20942,19 @@ async function adsDoctor(dir2) {
20780
20942
  console.log(` ✗ ${msg}`);
20781
20943
  failed += 1;
20782
20944
  };
20783
- const linkPath = join11(root2, ".zalify", "brand-repo.json");
20945
+ const linkPath = join12(root2, ".zalify", "brand-repo.json");
20784
20946
  let link = null;
20785
- if (existsSync11(linkPath)) {
20786
- link = JSON.parse(readFileSync9(linkPath, "utf8"));
20947
+ if (existsSync12(linkPath)) {
20948
+ link = JSON.parse(readFileSync10(linkPath, "utf8"));
20787
20949
  ok(`checkout linked to ${link?.repo} (slug ${link?.slug})`);
20788
20950
  } else {
20789
20951
  console.log(" - not inside a brand-instance checkout (repo checks only)");
20790
20952
  }
20791
20953
  let identity2 = null;
20792
- const identityPath = join11(root2, "identity.yaml");
20793
- if (existsSync11(identityPath)) {
20954
+ const identityPath = join12(root2, "identity.yaml");
20955
+ if (existsSync12(identityPath)) {
20794
20956
  try {
20795
- identity2 = $parse(readFileSync9(identityPath, "utf8"));
20957
+ identity2 = $parse(readFileSync10(identityPath, "utf8"));
20796
20958
  ok("identity.yaml parses");
20797
20959
  if (identity2?.workspace_id && identity2.workspace_id !== auth.workspaceId) {
20798
20960
  bad(`identity.yaml workspace_id (${identity2.workspace_id}) ≠ active workspace (${auth.workspaceId}) — is the CLI on the right workspace?`);
@@ -20858,8 +21020,8 @@ ${failed} problem${failed > 1 ? "s" : ""}.`);
20858
21020
  }
20859
21021
 
20860
21022
  // src/brand.ts
20861
- import { existsSync as existsSync12, mkdirSync as mkdirSync5, readdirSync as readdirSync4, readFileSync as readFileSync10, writeFileSync as writeFileSync10 } from "node:fs";
20862
- import { basename as basename5, join as join12, resolve as resolve10 } from "node:path";
21023
+ import { existsSync as existsSync13, mkdirSync as mkdirSync6, readdirSync as readdirSync6, readFileSync as readFileSync11, writeFileSync as writeFileSync11 } from "node:fs";
21024
+ import { basename as basename5, join as join13, resolve as resolve10 } from "node:path";
20863
21025
  var AUTHORING_MD = `# Brand authoring guide
20864
21026
 
20865
21027
  The rules for filling in this folder. An author — human or AI agent —
@@ -21162,17 +21324,17 @@ var MANIFEST_JSON = JSON.stringify({
21162
21324
  function brandInit(dir2 = ".", opts = {}) {
21163
21325
  const target = resolve10(dir2);
21164
21326
  const slug = basename5(target).toLowerCase().replaceAll(/[^a-z0-9-]+/g, "-").replaceAll(/^-+|-+$/g, "");
21165
- if (existsSync12(target) && readdirSync4(target).length > 0) {
21327
+ if (existsSync13(target) && readdirSync6(target).length > 0) {
21166
21328
  throw new Error(`${dir2} already exists and is not empty`);
21167
21329
  }
21168
21330
  const name = opts.name ?? slug.split("-").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
21169
21331
  const currency = (opts.currency ?? "USD").toUpperCase();
21170
- mkdirSync5(join12(target, "images"), { recursive: true });
21171
- writeFileSync10(join12(target, "AUTHORING.md"), AUTHORING_MD);
21172
- writeFileSync10(join12(target, "brand.md"), BRAND_MD(name));
21173
- writeFileSync10(join12(target, "store.json"), STORE_JSON(name, slug, opts.vertical ?? "<vertical>", currency));
21174
- writeFileSync10(join12(target, "catalog.json"), CATALOG_JSON(name, currency));
21175
- writeFileSync10(join12(target, "images", "manifest.json"), MANIFEST_JSON);
21332
+ mkdirSync6(join13(target, "images"), { recursive: true });
21333
+ writeFileSync11(join13(target, "AUTHORING.md"), AUTHORING_MD);
21334
+ writeFileSync11(join13(target, "brand.md"), BRAND_MD(name));
21335
+ writeFileSync11(join13(target, "store.json"), STORE_JSON(name, slug, opts.vertical ?? "<vertical>", currency));
21336
+ writeFileSync11(join13(target, "catalog.json"), CATALOG_JSON(name, currency));
21337
+ writeFileSync11(join13(target, "images", "manifest.json"), MANIFEST_JSON);
21176
21338
  console.log(`Scaffolded brand data in ${dir2}:`);
21177
21339
  for (const f of ["AUTHORING.md", "brand.md", "store.json", "catalog.json", "images/manifest.json"]) {
21178
21340
  console.log(` + ${f}`);
@@ -21187,19 +21349,19 @@ function brandValidate(dir2 = ".") {
21187
21349
  const problems = [];
21188
21350
  const warnings = [];
21189
21351
  const readJson2 = (rel) => {
21190
- const path9 = join12(target, rel);
21191
- if (!existsSync12(path9)) {
21352
+ const path9 = join13(target, rel);
21353
+ if (!existsSync13(path9)) {
21192
21354
  problems.push(`missing ${rel}`);
21193
21355
  return null;
21194
21356
  }
21195
21357
  try {
21196
- return JSON.parse(readFileSync10(path9, "utf8"));
21358
+ return JSON.parse(readFileSync11(path9, "utf8"));
21197
21359
  } catch (err) {
21198
21360
  problems.push(`${rel}: invalid JSON — ${err instanceof Error ? err.message : err}`);
21199
21361
  return null;
21200
21362
  }
21201
21363
  };
21202
- if (!existsSync12(join12(target, "brand.md")))
21364
+ if (!existsSync13(join13(target, "brand.md")))
21203
21365
  problems.push("missing brand.md");
21204
21366
  const store = readJson2("store.json");
21205
21367
  if (store && (!store.name || !store.slug))
@@ -21375,7 +21537,7 @@ function brandValidate(dir2 = ".") {
21375
21537
  // src/cli.ts
21376
21538
  var __dirname4 = dirname3(fileURLToPath3(import.meta.url));
21377
21539
  var require2 = createRequire2(import.meta.url);
21378
- var pkg = require2(join13(__dirname4, "..", "package.json"));
21540
+ var pkg = require2(join14(__dirname4, "..", "package.json"));
21379
21541
  try {
21380
21542
  updateNotifier({ pkg }).notify({
21381
21543
  isGlobal: true,
@@ -21457,7 +21619,7 @@ ads.command("list", { isDefault: true }).description("List the workspace's brand
21457
21619
  ads.command("remove <slug>").description("Deprecate a brand instance (record soft-deleted; the GitHub repo is left untouched)").action(async (slug) => {
21458
21620
  await adsRemove(slug);
21459
21621
  });
21460
- 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) => {
21622
+ 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").option("--account <folder>", "account folder under accounts/ (inferred when only one)").action(async (name, options) => {
21461
21623
  await adsNew(name, options);
21462
21624
  });
21463
21625
  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) => {
@@ -21488,6 +21650,10 @@ adsPlan.command("show <id>").description("Print one plan's content (id, or slug@
21488
21650
  ads.command("open").description("Open the Structure canvas for the active workspace").action(() => {
21489
21651
  adsOpen();
21490
21652
  });
21653
+ var adsCreative = ads.command("creative").description("Creatives for folder campaigns — generated through the canvas pipeline");
21654
+ adsCreative.command("generate <campaign>").description("Generate one image (safe zones + product fidelity) into the campaign's creatives/ — same pipeline, same asset library, stored once").requiredOption("--prompt <text>", "what the image should be").option("--orientation <o>", "square | landscape | portrait", "square").option("--ref <url...>", "product photos the image must stay faithful to").option("--name <name>", "filename stem (default: derived)").action(async (campaign, options) => {
21655
+ await adsCreativeGenerate({ campaign, ...options });
21656
+ });
21491
21657
  ads.command("doctor [dir]").description("Health checks that have each broken once: slug alignment, consent drift, timezone match, credential expiry").action(async (dir2) => {
21492
21658
  await adsDoctor(dir2);
21493
21659
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalify/cli",
3
- "version": "0.22.1",
3
+ "version": "0.24.0",
4
4
  "description": "Zalify CLI - command-line interface for Zalify",
5
5
  "type": "module",
6
6
  "main": "dist/cli.js",