frontpage-host 0.1.22 → 0.1.24

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.
package/AGENTS.md CHANGED
@@ -251,3 +251,8 @@ frontpage help site-building
251
251
  frontpage help agent
252
252
  frontpage doctor --json
253
253
  ```
254
+
255
+
256
+ ## People groups, stages, rules, and imports
257
+
258
+ Use `frontpage people overview`, `groups`, `stages`, `membership`, `rules`, `attachments`, `import`, and `company`. Use `frontpage mail campaign` for one-off group/stage/list audiences in Emails. Every editor action is available through these commands or `frontpage people workspace OPERATION --file payload.json`. Full operations and examples: [PEOPLE.md](./PEOPLE.md); public guide: https://frontpage.host/developers/people. Automated emails select a saved template or series. Campaigns use their own drafts.
package/PEOPLE.md ADDED
@@ -0,0 +1,169 @@
1
+ # People: groups, stages, rules, imports, and campaigns
2
+
3
+ Start with manage_people {operation:"overview"} and {operation:"sources"}. Results provide stable IDs. Do not invent IDs or identify a form by its display name. A form keeps its slug when renamed; a removed form needs a new rule source.
4
+
5
+ Call manage_people with {operation, payload:{...fields}}. For campaign operations add area:"campaign". The tool binds this site's identity and permission; never pass siteId or slug. All examples below are payloads. CLI equivalents: frontpage people workspace OPERATION --data 'JSON' --site SLUG --json, or --file payload.json. All operations also have named commands: frontpage people groups create, rules update, membership move, etc. Run frontpage help people for flags.
6
+
7
+ ## Groups and stages
8
+ A person can belong to many groups and one stage per group. Manual groups keep membership until an action changes it. Conditions groups reevaluate saved facts, including time. Unknown facts do not remove people. Manual include/exclude overrides conditions; override:null returns to automatic membership. hold:true stops this person's automatic changes for that group. Hidden people are excluded from campaign audiences and automated emails.
9
+
10
+ - groups.list {archived:false}; groups.get {id}; groups.create {label:"Customers",color:"emerald",mode:"manual"}; groups.update {id,label,color,mode,conditions}; groups.archive/restore {id}.
11
+ - Conditions example: groups.create {label:"Repeat buyers",mode:"conditions",conditions:{field:"purchaseCount",op:"gte",value:2}}.
12
+ - groups.preview {id,personId} or {conditions,cursor}. Preview sends nothing. Page through nextCursor.
13
+ - stages.create {groupId,label:"New",position:0,color:"sky",terminal:false}; stages.update {id,groupId,label,position,color,terminal}; stages.archive/restore {id,groupId}.
14
+ - membership.get {personId}; membership.add/remove {personId,groupId}; membership.move {personId,fromGroupId,groupId}; membership.stage {personId,groupId,stageId}; membership.advance {personId,groupId}; membership.override {personId,groupId,override:"include"|"exclude"|null}; membership.hold {personId,groupId,hold:true|false}. reset:true starts again at the first stage when adding/moving. Otherwise prior stage is retained on reentry. Advance follows ordered nonterminal stages and stops at a terminal stage; select a terminal stage explicitly.
15
+ - person.permission {personId,allowed:true|false} records the owner's email-permission decision. A saved unsubscribe or suppression still wins. Membership or platform signup never grants permission.
16
+
17
+ Existing tags become manual groups. Existing statuses become stages in Customer progress. Migration keeps person IDs, history, notes, follow-ups, and hidden state, and sends no email.
18
+
19
+ ## When / If / Then
20
+ rules.create/update {id?,name,trigger,conditions:null|CONDITION,actions:[ACTION],enabled:false|true}. Draft first, validate/preview, then enable. rules.list/get/enable/pause/archive {id}; rules.runs {ruleId?,cursor?,limit}; rules.retry {runId} or {ruleId}. Changed or paused rules cannot replay old runs. Temporary failures retry with backoff; permanent failures stay visible until corrected. Durable action receipts prevent duplicate sends on retries. Rule edits create a new version and do not replay past events. Membership cycles and conflicting stage targets are rejected.
21
+
22
+ WHEN events: person.added, person.updated, form.submitted (optional formSlug), booking.requested, signup.subscribed/resubscribed/unsubscribed, payment.paid/refunded, order.paid/refunded/fulfilled (order.paid accepts productId), square_order.paid, group.joined/left (groupId), stage.entered (groupId,stageId), group.elapsed (groupId,delayMinutes), stage.elapsed (groupId,stageId,delayMinutes). Use sources for available forms/products. The stored form slug and product itemId survive display-name changes. Missing sources fail validation instead of silently selecting something else.
23
+
24
+ IF accepts {all:[CONDITION,...]} or {any:[CONDITION,...]}, up to four levels/100 clauses. Leaf forms:
25
+ - {field:"email"|"phone"|"name",op:"is"|"is_not"|"contains"|"present",value?:text}
26
+ - {field:"lastSeenDays"|"purchaseCount"|"spentCents"|"lastPurchaseDays",op:"is"|"is_not"|"gte"|"lte"|"present",value?:integer}
27
+ - {field:"subscribed",op:"is"|"is_not",value:boolean}
28
+ - {groupId,stageId?,member:boolean}; {formSlug,submitted:boolean}.
29
+
30
+ THEN supports 1–10 ordered actions:
31
+ {type:"add_group"|"remove_group",groupId}; {type:"move_group",fromGroupId,groupId}; {type:"set_stage",groupId,stageId,fromStageIds?:[ids]}; {type:"advance_stage",groupId,fromStageIds?:[ids]}; {type:"set_follow_up",minutes}; {type:"clear_follow_up"}; {type:"send_template",templateId}; {type:"start_series",seriesId}; {type:"cancel_series",seriesId}.
32
+ Use fromStageIds to prevent late payment/form events from resetting later progress. Cancel series requires a group/stage trigger and affects only enrollments started for that group. Existing series enrollments remain once per email/series; starting again does not reset them.
33
+
34
+ Example: rules.create {name:"Contact leads",trigger:{event:"form.submitted",formSlug:"contact"},conditions:null,actions:[{type:"add_group",groupId:LEADS},{type:"set_follow_up",minutes:1440}],enabled:true}.
35
+ Example: rules.create {name:"Paid customers",trigger:{event:"order.paid"},conditions:{groupId:LEADS,member:true},actions:[{type:"move_group",fromGroupId:LEADS,groupId:CUSTOMERS}],enabled:true}.
36
+ Example: rules.create {name:"Follow up after two days",trigger:{event:"stage.elapsed",groupId:LEADS,stageId:NEW,delayMinutes:2880},conditions:{field:"subscribed",op:"is",value:true},actions:[{type:"send_template",templateId:TEMPLATE}],enabled:true}.
37
+
38
+ ## Attach an email
39
+ attachments.create {groupId,stageId?,emailKind:"template"|"series",emailId,name?,conditions?,enabled?}. This creates an ordinary editable rule on group.joined or stage.entered. Use one action label: Attach an email to this group/stage. Choose a saved email template or active series. Automated emails never use campaigns or inline content. Create/edit templates and series through the existing mail commands/tools. Pause/archive the returned rule to detach it.
40
+
41
+ ## CSV
42
+ import.preview {csv,mapping?,headersOnly?:true}; mapping maps email/phone/name/emailPermission to exact CSV headers. Example mapping:{email:"Email address",name:"Full name"}. RFC 4180 quoted commas/newlines, BOM and CRLF are supported. Files up to 5 MB and 10,000 people. Provide email and/or phone. Duplicate contacts collapse; conflicting email/phone matches are rejected rather than merged. Preview includes row errors and duplicates and sends nothing.
43
+ import.start {csv,mapping,groupIds?:[ids],requestKey:UUID}; import.get {id}; import.retry {id}. Reuse requestKey for the same import after a network error; never change its contents. The worker retains its row cursor, outcomes and errors. Retry only failed rows after fixing the identity conflict. Imports send no email. Optional emailPermission column must represent existing permission; it never reverses a saved withdrawal.
44
+
45
+ ## One-off campaigns in Emails
46
+ People selects the audience; Emails owns drafting and sending. No template-to-campaign action exists. Create a one_off draft with mail, then use manage_people area:"campaign":
47
+ preview {audience}; audience {documentId,audience}; send {documentId,requestKey}; get {documentId}.
48
+ Audience: {personIds:[ids]} OR {groupIds:[ids]} OR {stageIds:[ids]} OR {filters:{q?,source?,formSlug?,followUp?,lastSeenDays?}}. Combine ID lists as a union, filters narrow the result. {filters:{}} selects all eligible visible people. Preview returns matched/eligible/noEmail/suppressed/permissionMissing counts. Send freezes the eligible recipients and content. It deduplicates email addresses; missing email, hidden contacts, missing permission, and unsubscribe/suppression are excluded. Delivery rechecks permission. Queuing and delivery use the existing email worker, shared provider pacing and site/platform caps. Reuse the same requestKey after an ambiguous send response to prevent sending twice.
49
+ CLI: frontpage mail campaign preview --file audience.json; campaign audience --id DRAFT --file audience.json; campaign send --id DRAFT --request-key UUID; campaign get --id DRAFT.
50
+
51
+ ## Company account sync
52
+ company.get/set {enabled:true,groupId?} is restricted to a platform admin on landing. It backfills Frontpage accounts and adds later signups through a bounded saved cursor, with user ID deduplication. Backfill never fires rules; new accounts after sync activation can fire group entry rules, subject to email permission. It preserves owner edits and does not grant marketing permission. Ordinary tenants cannot query platform users. Pause with enabled:false. Existing contacts remain.
53
+
54
+ ## CLI recipes
55
+
56
+ Run `frontpage people overview --site demo --json` first. Copy the returned group, stage, person, email, and series IDs into the examples below. IDs are UUIDs, except a form slug or product item ID. Quotes around a JSON body are shell quotes; the JSON itself uses double quotes.
57
+
58
+ ```bash
59
+ frontpage people groups create --label Leads --color sky --site demo --json
60
+ frontpage people groups list --site demo --json
61
+ frontpage people stages create --group GROUP_ID --label New --position 0 --site demo --json
62
+ frontpage people stages create --group GROUP_ID --label Working --position 1 --site demo --json
63
+ frontpage people stages create --group GROUP_ID --label Won --position 2 --terminal true --site demo --json
64
+ frontpage people membership add --person PERSON_ID --group GROUP_ID --site demo --json
65
+ frontpage people membership stage --person PERSON_ID --group GROUP_ID --stage STAGE_ID --site demo --json
66
+ frontpage people membership hold --person PERSON_ID --group GROUP_ID --hold true --site demo --json
67
+ frontpage people membership move --person PERSON_ID --from-group OLD_GROUP_ID --group NEW_GROUP_ID --site demo --json
68
+ frontpage people membership remove --person PERSON_ID --group GROUP_ID --site demo --json
69
+ frontpage people membership override --person PERSON_ID --group GROUP_ID --override auto --site demo --json
70
+ frontpage people list --group GROUP_ID --stage STAGE_ID --site demo --json
71
+ frontpage people export --group GROUP_ID --out leads.csv --site demo
72
+ frontpage people attachments create --group GROUP_ID --email-kind template --email-id TEMPLATE_ID --enabled true --site demo --json
73
+ frontpage people attachments create --group GROUP_ID --stage STAGE_ID --email-kind series --email-id SERIES_ID --enabled true --site demo --json
74
+ frontpage people sources --site demo --json
75
+ frontpage people rules create --file rule.json --site demo --json
76
+ frontpage people rules preview --file rule.json --person PERSON_ID --site demo --json
77
+ frontpage people rules update --id RULE_ID --file rule.json --site demo --json
78
+ frontpage people rules enable --id RULE_ID --site demo --json
79
+ frontpage people rules pause --id RULE_ID --site demo --json
80
+ frontpage people rules runs --rule RULE_ID --site demo --json
81
+ frontpage people rules retry --run RUN_ID --site demo --json
82
+ frontpage people person permission --person PERSON_ID --allowed true --site demo --json
83
+ ```
84
+
85
+ Use this `rule.json`, replacing IDs and the form slug with values from `overview` and `sources`:
86
+
87
+ ```json
88
+ {
89
+ "name": "New contact leads",
90
+ "trigger": {"event": "form.submitted", "formSlug": "contact"},
91
+ "conditions": {"field": "email", "op": "present"},
92
+ "actions": [
93
+ {"type": "add_group", "groupId": "GROUP_ID"},
94
+ {"type": "set_follow_up", "minutes": 1440}
95
+ ],
96
+ "enabled": false
97
+ }
98
+ ```
99
+
100
+ A condition group example (`group.json`):
101
+
102
+ ```json
103
+ {"label":"Repeat buyers","mode":"conditions","conditions":{"all":[{"field":"purchaseCount","op":"gte","value":2},{"field":"lastPurchaseDays","op":"lte","value":90}]}}
104
+ ```
105
+
106
+ ```bash
107
+ frontpage people groups create --file group.json --site demo --json
108
+ frontpage people groups preview --file group.json --site demo --json
109
+ frontpage people groups update --id GROUP_ID --file group.json --site demo --json
110
+ frontpage people groups archive --id GROUP_ID --site demo --json
111
+ frontpage people groups restore --id GROUP_ID --site demo --json
112
+ ```
113
+
114
+ CSV mapping file (`mapping.json`):
115
+
116
+ ```json
117
+ {"mapping":{"email":"Email address","name":"Full name","phone":"Phone","emailPermission":"Permission"},"groupIds":["GROUP_ID"]}
118
+ ```
119
+
120
+ ```bash
121
+ frontpage people import preview --csv customers.csv --file mapping.json --site demo --json
122
+ frontpage people import start --csv customers.csv --file mapping.json --request-key REQUEST_UUID --site demo --json
123
+ frontpage people import get --id IMPORT_ID --site demo --json
124
+ frontpage people import retry --id IMPORT_ID --site demo --json
125
+ ```
126
+
127
+ Permission values are true/false or yes/no. Leave permission blank when unknown. Phone-only contacts can enter groups but cannot receive email. CSV import runs in saved batches; inspect the result for row errors before assuming every record was accepted. Fix an existing identity conflict, then retry failed rows. To correct the CSV itself, preview and start a new import with a new request key. A retry never changes the original file.
128
+
129
+ Campaign audience file (`audience.json`):
130
+
131
+ ```json
132
+ {"audience":{"groupIds":["GROUP_ID"]}}
133
+ ```
134
+
135
+ Use `stageIds` instead for one stage, or `personIds` for selected people. To select the whole visible People list use `{"audience":{"filters":{}}}`. Create an email draft using the existing email builder/CLI; campaigns do not start from templates.
136
+
137
+ ```bash
138
+ frontpage mail email create --file draft.json --name "Customer update" --site demo --json
139
+ frontpage mail campaign preview --file audience.json --site demo --json
140
+ frontpage mail campaign audience --id DRAFT_ID --file audience.json --site demo --json
141
+ frontpage mail campaign send --id DRAFT_ID --request-key REQUEST_UUID --site demo --json
142
+ frontpage mail campaign get --id DRAFT_ID --site demo --json
143
+ ```
144
+
145
+ Company sync is available only to a platform admin in the landing workspace:
146
+
147
+ ```bash
148
+ frontpage people company get --site landing --json
149
+ frontpage people company set --enabled true --site landing --json
150
+ frontpage people company set --enabled false --site landing --json
151
+ ```
152
+
153
+ ## API and agent parity
154
+
155
+ The same application service handles editor actions, the public CLI, and the site agent. Use POST `/api/v1/sites/SLUG/people/workspace` with an API key and body `{"operation":"groups.create","label":"Leads"}`. Campaign operations use POST `/api/v1/sites/SLUG/mail/campaign` with `operation: preview|audience|send|get`. Read actions require viewer access; writes require editor access. Public CLI access still requires Scale, Enterprise, or Internal. People and Emails keep their existing plan checks. Tenant IDs come from authenticated access, never the JSON body.
156
+
157
+ The agent uses `get_people_docs` for the complete reference and `manage_people` for every workspace action. For example `{"operation":"membership.add","payload":{"personId":"PERSON_ID","groupId":"GROUP_ID"}}`; a campaign uses `{"area":"campaign","operation":"preview","payload":{"audience":{"groupIds":["GROUP_ID"]}}}`. The agent can create templates and series with existing Email tools, then attach their IDs. No step requires opening the UI.
158
+
159
+ ## Timing, limits, and safe changes
160
+
161
+ The existing minute worker evaluates new events, saved imports, company account sync, dynamic membership, timed rules, and pending actions. A busy queue can take longer than one minute. Form labels can change without breaking rules: the saved form slug is the identity. Changing/removing that slug creates a different source; choose it explicitly and update the rule. Product titles can change; rules keep the product item ID. Rename groups/stages freely; rules keep their IDs. Archive rather than deleting history.
162
+
163
+ Rules act on events after activation, not historical submissions. Every real group entry or stage entry can start its attached email; a duplicate event or retry cannot send it twice. An existing series enrollment does not restart on reentry. A hold stops automatic group changes and its attachment; lifting it does not replay failed work automatically. Review the run and retry deliberately. A queued series continues unless a rule explicitly stops that group's series. Missing permission, unsubscribe, and suppression always stop automated delivery.
164
+
165
+ Temporary database/provider failures retry up to eight attempts with delays from one minute to one hour. Permanent errors remain visible with the failed action and a manual retry. Earlier completed actions retain their receipts. Paused, archived, or changed rules do not replay old work. Condition previews and import previews send nothing. Conditions groups reevaluate all contacts in rotating batches; they do not require browser listeners.
166
+
167
+ Campaign queuing uses batches of 100. Sending uses the existing send window, hourly/monthly/site caps, platform daily cap, kill switch, and a shared provider slot across workers. Status `queued` means messages reached the existing send queue, not that every email was delivered. `campaign get` reports the accepted, sent, pending, failed, and skipped work. An unsubscribe after audience selection still prevents delivery.
168
+
169
+ Legacy tag/status commands remain compatible with converted groups/stages. New integrations should use group/stage IDs; historical fields remain for older callers and exports. All existing person IDs, notes, follow-ups, hidden state, source events, and external CRM destination behavior remain intact.
package/README.md CHANGED
@@ -55,3 +55,8 @@ Machine-readable: `frontpage help --json` and global `--json` / `--quiet` on com
55
55
  ## Agents
56
56
 
57
57
  See [AGENTS.md](./AGENTS.md) and [llms.txt](./llms.txt) in this package.
58
+
59
+
60
+ ## People groups, stages, rules, and imports
61
+
62
+ Use `frontpage people overview`, `groups`, `stages`, `membership`, `rules`, `attachments`, `import`, and `company`. Use `frontpage mail campaign` for one-off group/stage/list audiences in Emails. Every editor action is available through these commands or `frontpage people workspace OPERATION --file payload.json`. Full operations and examples: [PEOPLE.md](./PEOPLE.md); public guide: https://frontpage.host/developers/people. Automated emails select a saved template or series. Campaigns use their own drafts.
@@ -1,3 +1,4 @@
1
+ import { runCampaign } from "./people-workspace.js";
1
2
  import { readFile } from "node:fs/promises";
2
3
  import { resolveSlug } from "../site.js";
3
4
  import { CliHttpError, EXIT_OK, EXIT_USAGE, exitCodeForError, v1Delete, v1Get, v1Patch, v1Post, v1Put, } from "../http/client.js";
@@ -79,6 +80,8 @@ export async function runMail(args) {
79
80
  try {
80
81
  const { slug, rest } = await resolveSlug(args);
81
82
  const group = rest[0];
83
+ if (group === "campaign")
84
+ return await runCampaign(slug, rest.slice(1));
82
85
  const base = `/api/v1/sites/${encodeURIComponent(slug)}/mail`;
83
86
  if (group === "settings") {
84
87
  const sub = rest[1] ?? "get";
@@ -0,0 +1,3 @@
1
+ export declare function isPeopleWorkspaceCommand(sub: string | undefined): boolean;
2
+ export declare function runPeopleWorkspace(slug: string, args: string[]): Promise<number>;
3
+ export declare function runCampaign(slug: string, args: string[]): Promise<number>;
@@ -0,0 +1,137 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { assertNoUnknownFlags, takeFlag } from "../argv.js";
3
+ import { CliHttpError, EXIT_OK, EXIT_USAGE, v1Post } from "../http/client.js";
4
+ import { emitOk, isJson } from "../runtime.js";
5
+ const AREAS = [
6
+ "groups",
7
+ "stages",
8
+ "membership",
9
+ "rules",
10
+ "import",
11
+ "attachments",
12
+ "person",
13
+ "company",
14
+ ];
15
+ export function isPeopleWorkspaceCommand(sub) {
16
+ return (!!sub &&
17
+ (AREAS.includes(sub) ||
18
+ ["overview", "sources", "audience", "workspace"].includes(sub)));
19
+ }
20
+ export async function runPeopleWorkspace(slug, args) {
21
+ const work = [...args], area = work.shift();
22
+ const operation = area === "workspace"
23
+ ? work.shift()
24
+ : area === "overview" || area === "sources"
25
+ ? area
26
+ : `${area}.${work.shift() ?? (area === "company" ? "get" : area === "audience" ? "preview" : "list")}`;
27
+ const file = takeFlag(work, "--file"), json = takeFlag(work, "--data");
28
+ if (file && json)
29
+ throw new CliHttpError("Use --file or --data, not both.", EXIT_USAGE, 0, "bad_request");
30
+ let payload = {};
31
+ if (file || json) {
32
+ try {
33
+ const parsed = JSON.parse(file ? await readFile(file, "utf8") : json);
34
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
35
+ throw Error();
36
+ payload = parsed;
37
+ }
38
+ catch {
39
+ throw new CliHttpError("Provide a JSON object through --file or --data.", EXIT_USAGE, 0, "bad_request");
40
+ }
41
+ }
42
+ for (const [flag, key] of Object.entries({
43
+ "--id": "id",
44
+ "--name": "name",
45
+ "--label": "label",
46
+ "--group": "groupId",
47
+ "--stage": "stageId",
48
+ "--person": "personId",
49
+ "--from-group": "fromGroupId",
50
+ "--mode": "mode",
51
+ "--color": "color",
52
+ "--run": "runId",
53
+ "--rule": "ruleId",
54
+ "--email-id": "emailId",
55
+ "--email-kind": "emailKind",
56
+ "--override": "override",
57
+ "--cursor": "cursor",
58
+ "--request-key": "requestKey",
59
+ })) {
60
+ const v = takeFlag(work, flag);
61
+ if (v !== undefined)
62
+ payload[key] = key === "override" && v === "auto" ? null : v;
63
+ }
64
+ for (const [flag, key] of Object.entries({
65
+ "--position": "position",
66
+ "--limit": "limit",
67
+ })) {
68
+ const v = takeFlag(work, flag);
69
+ if (v !== undefined) {
70
+ const n = Number(v);
71
+ if (!Number.isInteger(n))
72
+ throw new CliHttpError(`${flag} requires an integer.`, EXIT_USAGE, 0, "bad_request");
73
+ payload[key] = n;
74
+ }
75
+ }
76
+ for (const [flag, key] of Object.entries({
77
+ "--enabled": "enabled",
78
+ "--hold": "hold",
79
+ "--terminal": "terminal",
80
+ "--allowed": "allowed",
81
+ "--archived": "archived",
82
+ "--reset": "reset",
83
+ })) {
84
+ const v = takeFlag(work, flag);
85
+ if (v !== undefined) {
86
+ if (v !== "true" && v !== "false")
87
+ throw new CliHttpError(`${flag} is true or false.`, EXIT_USAGE, 0, "bad_request");
88
+ payload[key] = v === "true";
89
+ }
90
+ }
91
+ const csv = takeFlag(work, "--csv");
92
+ if (csv)
93
+ payload.csv = await readFile(csv, "utf8");
94
+ if (operation === "import.start" && !payload.requestKey)
95
+ throw new CliHttpError("Provide --request-key with a stable UUID so retries reuse this import.", EXIT_USAGE, 0, "bad_request");
96
+ assertNoUnknownFlags(work, "people");
97
+ if (work.length || !operation)
98
+ throw new CliHttpError("Unexpected arguments. Run frontpage help people.", EXIT_USAGE, 0, "bad_request");
99
+ const result = await v1Post(`/api/v1/sites/${encodeURIComponent(slug)}/people/workspace`, { ...payload, operation });
100
+ if (isJson())
101
+ emitOk("people", result);
102
+ else
103
+ console.log(JSON.stringify(result, null, 2));
104
+ return EXIT_OK;
105
+ }
106
+ export async function runCampaign(slug, args) {
107
+ const work = [...args], operation = work.shift() ?? "get", file = takeFlag(work, "--file"), data = takeFlag(work, "--data"), documentId = takeFlag(work, "--id"), requestKey = takeFlag(work, "--request-key");
108
+ if (file && data)
109
+ throw new CliHttpError("Use --file or --data.", EXIT_USAGE, 0, "bad_request");
110
+ let payload = {};
111
+ if (file || data) {
112
+ try {
113
+ payload = JSON.parse(file ? await readFile(file, "utf8") : data);
114
+ if (!payload || Array.isArray(payload) || typeof payload !== "object")
115
+ throw Error();
116
+ }
117
+ catch {
118
+ throw new CliHttpError("Provide a JSON object.", EXIT_USAGE, 0, "bad_request");
119
+ }
120
+ }
121
+ assertNoUnknownFlags(work, "mail");
122
+ if (work.length)
123
+ throw new CliHttpError("Unexpected campaign arguments.", EXIT_USAGE, 0, "bad_request");
124
+ const result = await v1Post(`/api/v1/sites/${encodeURIComponent(slug)}/mail/campaign`, {
125
+ ...payload,
126
+ operation,
127
+ ...(documentId ? { documentId } : {}),
128
+ ...(operation === "send"
129
+ ? { requestKey: requestKey ?? payload.requestKey }
130
+ : {}),
131
+ });
132
+ if (isJson())
133
+ emitOk("mail", result);
134
+ else
135
+ console.log(JSON.stringify(result, null, 2));
136
+ return EXIT_OK;
137
+ }
@@ -1,3 +1,4 @@
1
+ import { isPeopleWorkspaceCommand, runPeopleWorkspace } from "./people-workspace.js";
1
2
  import { writeFile } from "node:fs/promises";
2
3
  import { resolveSlug } from "../site.js";
3
4
  import { CliHttpError, EXIT_OK, EXIT_USAGE, apiBase, exitCodeForError, getBearerAuthHeader, httpErrorFromV1, v1Delete, v1Get, v1Patch, v1Post, } from "../http/client.js";
@@ -73,11 +74,15 @@ export async function runPeople(args) {
73
74
  try {
74
75
  const { slug, rest } = await resolveSlug(args);
75
76
  const sub = rest[0];
77
+ if (isPeopleWorkspaceCommand(sub))
78
+ return await runPeopleWorkspace(slug, rest);
76
79
  const base = `/api/v1/sites/${encodeURIComponent(slug)}/people`;
77
80
  if (!sub || sub === "list") {
78
81
  const work = sub === "list" ? rest.slice(1) : [...rest];
79
82
  const q = takeFlag(work, "--q") ?? takeFlag(work, "--query");
80
83
  const status = takeFlag(work, "--status");
84
+ const groupId = takeFlag(work, "--group");
85
+ const stageId = takeFlag(work, "--stage");
81
86
  const source = takeFlag(work, "--source");
82
87
  const formSlug = takeFlag(work, "--form-slug");
83
88
  const followUp = takeFlag(work, "--follow-up");
@@ -91,6 +96,10 @@ export async function runPeople(args) {
91
96
  const params = new URLSearchParams();
92
97
  if (q)
93
98
  params.set("q", q);
99
+ if (groupId)
100
+ params.set("groupId", groupId);
101
+ if (stageId)
102
+ params.set("stageId", stageId);
94
103
  if (status)
95
104
  params.set("status", status);
96
105
  if (source)
@@ -313,6 +322,8 @@ export async function runPeople(args) {
313
322
  const work = rest.slice(1);
314
323
  const q = takeFlag(work, "--q") ?? takeFlag(work, "--query");
315
324
  const status = takeFlag(work, "--status");
325
+ const groupId = takeFlag(work, "--group");
326
+ const stageId = takeFlag(work, "--stage");
316
327
  const source = takeFlag(work, "--source");
317
328
  const formSlug = takeFlag(work, "--form-slug");
318
329
  const followUp = takeFlag(work, "--follow-up");
@@ -325,6 +336,10 @@ export async function runPeople(args) {
325
336
  const params = new URLSearchParams();
326
337
  if (q)
327
338
  params.set("q", q);
339
+ if (groupId)
340
+ params.set("groupId", groupId);
341
+ if (stageId)
342
+ params.set("stageId", stageId);
328
343
  if (status)
329
344
  params.set("status", status);
330
345
  if (source)
@@ -32,7 +32,7 @@ export async function runSettings(args) {
32
32
  }
33
33
  const buildModel = takeFlag(work, "--build-model");
34
34
  if (buildModel !== undefined)
35
- body.buildModel = buildModel;
35
+ body.buildModel = buildModel === "default" ? null : buildModel;
36
36
  const brandName = takeFlag(work, "--brand-name");
37
37
  const brandLogo = takeFlag(work, "--brand-logo");
38
38
  if (work.includes("--brand-reset")) {
@@ -164,7 +164,7 @@ export async function runShop(args) {
164
164
  const group = rest[0];
165
165
  const base = `/api/v1/sites/${encodeURIComponent(slug)}/shop`;
166
166
  if (!group)
167
- return emitUsage("shop", USAGE);
167
+ return emitUsage("store", USAGE);
168
168
  if (group === "items") {
169
169
  const sub = rest[1];
170
170
  const work = rest.slice(2);
@@ -181,7 +181,7 @@ export async function runShop(args) {
181
181
  const suffix = qs.toString() ? `?${qs.toString()}` : "";
182
182
  const data = await v1Get(`${base}/items${suffix}`);
183
183
  if (isJson()) {
184
- emitOk("shop", data);
184
+ emitOk("store", data);
185
185
  return EXIT_OK;
186
186
  }
187
187
  const rows = data.items ?? [];
@@ -198,7 +198,7 @@ export async function runShop(args) {
198
198
  assertNoUnknownFlags(work, "store");
199
199
  const data = await v1Get(`${base}/items/${encodeURIComponent(itemId)}`);
200
200
  if (isJson()) {
201
- emitOk("shop", data);
201
+ emitOk("store", data);
202
202
  return EXIT_OK;
203
203
  }
204
204
  printItem(data.item);
@@ -271,7 +271,7 @@ export async function runShop(args) {
271
271
  }
272
272
  const data = await v1Post(`${base}/items`, body);
273
273
  if (isJson()) {
274
- emitOk("shop", data);
274
+ emitOk("store", data);
275
275
  return EXIT_OK;
276
276
  }
277
277
  printItem(data.item);
@@ -313,7 +313,7 @@ export async function runShop(args) {
313
313
  }
314
314
  const data = await v1Patch(`${base}/items/${encodeURIComponent(itemId)}`, body);
315
315
  if (isJson()) {
316
- emitOk("shop", data);
316
+ emitOk("store", data);
317
317
  return EXIT_OK;
318
318
  }
319
319
  printItem(data.item);
@@ -324,7 +324,7 @@ export async function runShop(args) {
324
324
  assertNoUnknownFlags(work, "store");
325
325
  const data = await v1Post(`${base}/items/${encodeURIComponent(itemId)}/archive`, {});
326
326
  if (isJson()) {
327
- emitOk("shop", data);
327
+ emitOk("store", data);
328
328
  return EXIT_OK;
329
329
  }
330
330
  printItem(data.item);
@@ -337,7 +337,7 @@ export async function runShop(args) {
337
337
  assertNoUnknownFlags(work, "store");
338
338
  const data = await v1Post(`${base}/items/${encodeURIComponent(itemId)}/activate`, { active: active ?? true });
339
339
  if (isJson()) {
340
- emitOk("shop", data);
340
+ emitOk("store", data);
341
341
  return EXIT_OK;
342
342
  }
343
343
  printItem(data.item);
@@ -349,7 +349,7 @@ export async function runShop(args) {
349
349
  assertNoUnknownFlags(work, "store");
350
350
  const data = await v1Put(`${base}/items/${encodeURIComponent(itemId)}/images`, { images: urls });
351
351
  if (isJson()) {
352
- emitOk("shop", data);
352
+ emitOk("store", data);
353
353
  return EXIT_OK;
354
354
  }
355
355
  printItem(data.item);
@@ -396,13 +396,13 @@ export async function runShop(args) {
396
396
  }
397
397
  const data = await v1Post(`${base}/items/${encodeURIComponent(itemId)}/stock`, body);
398
398
  if (isJson()) {
399
- emitOk("shop", data);
399
+ emitOk("store", data);
400
400
  return EXIT_OK;
401
401
  }
402
402
  printStock(data);
403
403
  return EXIT_OK;
404
404
  }
405
- return emitUsage("shop", USAGE);
405
+ return emitUsage("store", USAGE);
406
406
  }
407
407
  if (group === "variants") {
408
408
  const sub = rest[1];
@@ -429,7 +429,7 @@ export async function runShop(args) {
429
429
  active,
430
430
  });
431
431
  if (isJson()) {
432
- emitOk("shop", data);
432
+ emitOk("store", data);
433
433
  return EXIT_OK;
434
434
  }
435
435
  printItem(data.item);
@@ -460,7 +460,7 @@ export async function runShop(args) {
460
460
  }
461
461
  const data = await v1Patch(`${base}/items/${encodeURIComponent(itemId)}/variants/${encodeURIComponent(variantId)}`, body);
462
462
  if (isJson()) {
463
- emitOk("shop", data);
463
+ emitOk("store", data);
464
464
  return EXIT_OK;
465
465
  }
466
466
  printItem(data.item);
@@ -472,13 +472,13 @@ export async function runShop(args) {
472
472
  assertNoUnknownFlags(work, "store");
473
473
  const data = await v1Post(`${base}/items/${encodeURIComponent(itemId)}/variants/${encodeURIComponent(variantId)}/archive`, {});
474
474
  if (isJson()) {
475
- emitOk("shop", data);
475
+ emitOk("store", data);
476
476
  return EXIT_OK;
477
477
  }
478
478
  printItem(data.item);
479
479
  return EXIT_OK;
480
480
  }
481
- return emitUsage("shop", USAGE);
481
+ return emitUsage("store", USAGE);
482
482
  }
483
483
  if (group === "categories") {
484
484
  const sub = rest[1];
@@ -490,7 +490,7 @@ export async function runShop(args) {
490
490
  const qs = archived ? "?archived=1" : "";
491
491
  const data = await v1Get(`${base}/categories${qs}`);
492
492
  if (isJson()) {
493
- emitOk("shop", data);
493
+ emitOk("store", data);
494
494
  return EXIT_OK;
495
495
  }
496
496
  const rows = data.categories ?? [];
@@ -517,7 +517,7 @@ export async function runShop(args) {
517
517
  label,
518
518
  });
519
519
  if (isJson()) {
520
- emitOk("shop", data);
520
+ emitOk("store", data);
521
521
  return EXIT_OK;
522
522
  }
523
523
  console.log(`${data.category.slug} ${data.category.label}`);
@@ -532,7 +532,7 @@ export async function runShop(args) {
532
532
  }
533
533
  const data = await v1Patch(`${base}/categories/${encodeURIComponent(slug)}`, { label });
534
534
  if (isJson()) {
535
- emitOk("shop", data);
535
+ emitOk("store", data);
536
536
  return EXIT_OK;
537
537
  }
538
538
  console.log(`${data.category.slug} ${data.category.label}`);
@@ -543,13 +543,13 @@ export async function runShop(args) {
543
543
  assertNoUnknownFlags(work, "store");
544
544
  const data = await v1Post(`${base}/categories/${encodeURIComponent(slug)}/archive`, {});
545
545
  if (isJson()) {
546
- emitOk("shop", data);
546
+ emitOk("store", data);
547
547
  return EXIT_OK;
548
548
  }
549
549
  console.log(`${data.category.slug} archived`);
550
550
  return EXIT_OK;
551
551
  }
552
- return emitUsage("shop", USAGE);
552
+ return emitUsage("store", USAGE);
553
553
  }
554
554
  if (group === "settings") {
555
555
  const sub = rest[1] ?? "get";
@@ -558,7 +558,7 @@ export async function runShop(args) {
558
558
  assertNoUnknownFlags(work, "store");
559
559
  const data = await v1Get(`${base}/settings`);
560
560
  if (isJson()) {
561
- emitOk("shop", data);
561
+ emitOk("store", data);
562
562
  return EXIT_OK;
563
563
  }
564
564
  const s = data.settings ?? {};
@@ -614,7 +614,7 @@ export async function runShop(args) {
614
614
  freeShippingOverCents: freeCents,
615
615
  });
616
616
  if (isJson())
617
- emitOk("shop", data);
617
+ emitOk("store", data);
618
618
  else
619
619
  console.log("Fulfillment saved.");
620
620
  return EXIT_OK;
@@ -627,7 +627,7 @@ export async function runShop(args) {
627
627
  }
628
628
  const data = await v1Post(`${base}/settings/currency`, { currency });
629
629
  if (isJson())
630
- emitOk("shop", data);
630
+ emitOk("store", data);
631
631
  else
632
632
  console.log("Currency saved.");
633
633
  return EXIT_OK;
@@ -640,7 +640,7 @@ export async function runShop(args) {
640
640
  }
641
641
  const data = await v1Post(`${base}/settings/checkout`, { checkout });
642
642
  if (isJson())
643
- emitOk("shop", data);
643
+ emitOk("store", data);
644
644
  else
645
645
  console.log(checkout === "instant" ? "Checkout is Pay now." : "Checkout is add to cart.");
646
646
  return EXIT_OK;
@@ -654,12 +654,12 @@ export async function runShop(args) {
654
654
  cents,
655
655
  });
656
656
  if (isJson())
657
- emitOk("shop", data);
657
+ emitOk("store", data);
658
658
  else
659
659
  console.log(off ? "Service fee off." : "Service fee saved.");
660
660
  return EXIT_OK;
661
661
  }
662
- return emitUsage("shop", USAGE);
662
+ return emitUsage("store", USAGE);
663
663
  }
664
664
  if (group === "connect") {
665
665
  const work = rest.slice(1);
@@ -668,7 +668,7 @@ export async function runShop(args) {
668
668
  const data = await v1Post(`${base}/connect`, { action: "connect" });
669
669
  const already = data.alreadyConnected === true;
670
670
  if (isJson()) {
671
- emitOk("shop", data);
671
+ emitOk("store", data);
672
672
  }
673
673
  else {
674
674
  const url = resolveOpenUrl(data.connectUrl);
@@ -688,7 +688,7 @@ export async function runShop(args) {
688
688
  assertNoUnknownFlags(work, "store");
689
689
  const data = await v1Get(`${base}/connect`);
690
690
  if (isJson()) {
691
- emitOk("shop", data);
691
+ emitOk("store", data);
692
692
  return EXIT_OK;
693
693
  }
694
694
  const connection = (data.connection ?? {});
@@ -707,10 +707,10 @@ export async function runShop(args) {
707
707
  assertNoUnknownFlags(work, "store");
708
708
  const refuse = requireYesOrTty("disconnect the store (shared Stripe account with payments)");
709
709
  if (refuse)
710
- return emitUsage("shop", refuse);
710
+ return emitUsage("store", refuse);
711
711
  const data = await v1Post(`${base}/connect`, { action: "disconnect" });
712
712
  if (isJson()) {
713
- emitOk("shop", data);
713
+ emitOk("store", data);
714
714
  return EXIT_OK;
715
715
  }
716
716
  console.log(typeof data.message === "string" ? data.message : "Disconnected.");
@@ -721,7 +721,7 @@ export async function runShop(args) {
721
721
  assertNoUnknownFlags(work, "store");
722
722
  const data = await v1Post(`${base}/import`, {});
723
723
  if (isJson()) {
724
- emitOk("shop", data);
724
+ emitOk("store", data);
725
725
  return EXIT_OK;
726
726
  }
727
727
  const imported = data.imported ?? [];
@@ -748,7 +748,7 @@ export async function runShop(args) {
748
748
  assertNoUnknownFlags(work, "store");
749
749
  const data = await v1Get(`${base}/${noun}`);
750
750
  if (isJson()) {
751
- emitOk("shop", data);
751
+ emitOk("store", data);
752
752
  return EXIT_OK;
753
753
  }
754
754
  const rows = data[noun] ?? [];
@@ -766,17 +766,17 @@ export async function runShop(args) {
766
766
  assertNoUnknownFlags(work, "store");
767
767
  const data = await v1Get(`${base}/${noun}/${encodeURIComponent(id)}`);
768
768
  if (isJson()) {
769
- emitOk("shop", data);
769
+ emitOk("store", data);
770
770
  return EXIT_OK;
771
771
  }
772
772
  console.log(JSON.stringify(data[noun === "orders" ? "order" : "payment"], null, 2));
773
773
  return EXIT_OK;
774
774
  }
775
- return emitUsage("shop", USAGE);
775
+ return emitUsage("store", USAGE);
776
776
  }
777
- return emitUsage("shop", USAGE);
777
+ return emitUsage("store", USAGE);
778
778
  }
779
779
  catch (err) {
780
- return emitErr("shop", err);
780
+ return emitErr("store", err);
781
781
  }
782
782
  }
@@ -34,17 +34,18 @@ export const MODULE_COMMANDS = [
34
34
  {
35
35
  key: "callbar",
36
36
  name: "Call bar",
37
- summary: "Phone / SMS bar. Typed get/set for phone, sms, and label.",
37
+ summary: "Phone / SMS bar. Typed get/set for phone, sms, call, and label.",
38
38
  genericActions: GENERIC_ACTIONS,
39
39
  extraActions: [
40
40
  "get",
41
- "set --phone +1… [--sms yes|no] [--label \"Call\"]",
41
+ "set --phone +1… [--sms yes|no] [--call yes|no] [--label \"Call\"]",
42
42
  ],
43
43
  notYet: [],
44
44
  examples: [
45
45
  "frontpage modules callbar",
46
46
  "frontpage modules callbar get",
47
47
  "frontpage modules callbar set --phone +14085551234 --sms no",
48
+ "frontpage modules callbar set --call no --sms yes",
48
49
  "frontpage modules callbar docs",
49
50
  `frontpage modules config callbar --config '{"phone":"+1…"}'`,
50
51
  ],
@@ -59,7 +60,7 @@ export const MODULE_COMMANDS = [
59
60
  genericActions: GENERIC_ACTIONS,
60
61
  extraActions: [
61
62
  "get [--instance <slug>]",
62
- "set --instance <slug> [--notify-email] [--confirm yes|no]",
63
+ "set --instance <slug> [--notify-email] [--confirm yes|no] [--confirm-template <id>]",
63
64
  "destinations [--instance <slug>]",
64
65
  "connect-destination <privyr|hubspot|pipedrive> --token \"…\" [--lead-source \"…\"]",
65
66
  "attach-destination --instance <slug> --provider <id>",
@@ -74,6 +75,7 @@ export const MODULE_COMMANDS = [
74
75
  "frontpage modules forms get",
75
76
  "frontpage modules forms get --instance lead-gen",
76
77
  "frontpage modules forms set --instance lead-gen --notify-email hi@example.com",
78
+ "frontpage modules forms set --instance lead-gen --confirm-template <template-id>",
77
79
  "frontpage modules forms destinations",
78
80
  "frontpage modules forms connect-destination privyr --token \"…\"",
79
81
  "frontpage modules forms attach-destination --instance lead-gen --provider privyr",
@@ -83,6 +85,7 @@ export const MODULE_COMMANDS = [
83
85
  notes: [
84
86
  "Without --instance, get lists each form (id, status, notifyEmail). Do not target `default`.",
85
87
  "Confirmation emails require a paid plan (same as Modules).",
88
+ "--confirm-template sets that form's saved template id. Every token must be one of the form's input names. An empty value clears it. A series is not attached to a form.",
86
89
  "Destinations copy leads to HubSpot, Pipedrive, or Privyr. Connect once, then attach each form. Token write is admin (Privyr: paste the Incoming Webhook URL, not a token). request-destination-setup lights the editor amber bar (editor key). remove-destination requires --yes. Locations: https://frontpage.host/developers/modules#forms-destinations",
87
90
  ],
88
91
  },
@@ -29,6 +29,7 @@ function printLine(label, value) {
29
29
  }
30
30
  function printCallbar(config) {
31
31
  printLine("phone", dash(config.phone));
32
+ printLine("call", yesNo(config.call));
32
33
  printLine("sms", yesNo(config.sms));
33
34
  printLine("label", dash(config.label));
34
35
  }
@@ -45,6 +46,9 @@ function printConfirmable(config, confirmations) {
45
46
  printLine("confirm", `${yesNo(on)} (paid plan required)`);
46
47
  else
47
48
  printLine("confirm", yesNo(on));
49
+ if (typeof config.confirmTemplateId === "string") {
50
+ printLine("template", config.confirmTemplateId.trim() || "—");
51
+ }
48
52
  }
49
53
  function printFormsList(instances, destinations) {
50
54
  if (!instances.length) {
@@ -108,10 +112,12 @@ export async function runTypedConfig(key, slug, action, flags) {
108
112
  const instanceId = takeFlag(work, "--instance");
109
113
  const phone = takeFlag(work, "--phone");
110
114
  const sms = takeFlag(work, "--sms");
115
+ const call = takeFlag(work, "--call");
111
116
  const label = takeFlag(work, "--label");
112
117
  const notifyEmail = takeFlag(work, "--notify-email");
113
118
  const hours = takeFlag(work, "--hours");
114
119
  const confirmRaw = takeFlag(work, "--confirm");
120
+ const confirmTemplate = takeFlag(work, "--confirm-template");
115
121
  assertNoUnknownFlags(work, "modules");
116
122
  let confirmEnabled;
117
123
  try {
@@ -127,6 +133,8 @@ export async function runTypedConfig(key, slug, action, flags) {
127
133
  body.phone = phone;
128
134
  if (sms !== undefined)
129
135
  body.sms = sms;
136
+ if (call !== undefined)
137
+ body.call = call;
130
138
  if (label !== undefined)
131
139
  body.label = label;
132
140
  if (notifyEmail !== undefined)
@@ -135,8 +143,14 @@ export async function runTypedConfig(key, slug, action, flags) {
135
143
  body.hours = hours;
136
144
  if (confirmEnabled !== undefined)
137
145
  body.confirmEnabled = confirmEnabled;
146
+ if (confirmTemplate !== undefined) {
147
+ if (key !== "forms") {
148
+ return emitUsage("modules", `--confirm-template is only for forms.\n\n${usage}`);
149
+ }
150
+ body.confirmTemplateId = confirmTemplate.trim();
151
+ }
138
152
  if (key === "forms" && !instanceId) {
139
- return emitUsage("modules", `Usage: frontpage modules forms set --instance <slug> [--notify-email] [--confirm yes|no]\n\n${usage}`);
153
+ return emitUsage("modules", `Usage: frontpage modules forms set --instance <slug> [--notify-email] [--confirm yes|no] [--confirm-template <id>]\n\n${usage}`);
140
154
  }
141
155
  const data = await v1Post(pathFor(slug, key), body);
142
156
  if (isJson()) {
package/dist/registry.js CHANGED
@@ -333,8 +333,10 @@ export const COMMANDS = [
333
333
  "frontpage modules google_reviews enable",
334
334
  "frontpage modules callbar get",
335
335
  "frontpage modules callbar set --phone +14085551234 --sms no",
336
+ "frontpage modules callbar set --call no --sms yes",
336
337
  "frontpage modules forms get",
337
338
  "frontpage modules forms set --instance lead-gen --notify-email hi@example.com",
339
+ "frontpage modules forms set --instance lead-gen --confirm-template <template-id>",
338
340
  "frontpage store connect --no-open",
339
341
  "frontpage modules docs forms",
340
342
  "frontpage modules docs google_reviews",
@@ -437,10 +439,16 @@ export const COMMANDS = [
437
439
  },
438
440
  {
439
441
  name: "people",
440
- summary: "List and manage site People (not inbox, not a module)",
441
- usage: "frontpage people list|get|add|set|note|rm-note|follow-up|hide|unhide|tag|export [--site <slug>] [--json]",
442
+ summary: "People, groups, stages, rules, imports, and campaign audiences",
443
+ usage: "frontpage people overview|sources|groups|stages|membership|rules|attachments|import|company|person|audience|workspace … [--site <slug>] [--json]; list|get|add|set|note|rm-note|follow-up|hide|unhide|tag|export remain available",
442
444
  topics: ["content"],
443
445
  examples: [
446
+ "frontpage people overview --json",
447
+ "frontpage people groups create --label Leads",
448
+ "frontpage people stages create --group GROUP_ID --label New --position 0",
449
+ "frontpage people membership add --person PERSON_ID --group GROUP_ID",
450
+ "frontpage people rules create --file rule.json",
451
+ "frontpage people import start --csv customers.csv --file mapping.json --request-key REQUEST_UUID",
444
452
  "frontpage people list",
445
453
  "frontpage people add --email alex@example.com --name Alex",
446
454
  "frontpage people set <id> --status working",
@@ -453,6 +461,10 @@ export const COMMANDS = [
453
461
  "frontpage people export",
454
462
  ],
455
463
  flags: [
464
+ "Workspace: groups list|get|create|update|archive|restore|preview; stages create|update|archive|restore; membership get|add|remove|move|stage|advance|override|hold; rules list|get|create|update|enable|pause|archive|validate|preview|runs|retry; attachments create; import preview|start|get|retry; person permission; company get|set; audience preview; overview; sources",
465
+ "All workspace operations accept --file JSON or --data JSON. Named flags: --id --label --name --group --stage --person --from-group --mode --color --position --terminal --enabled --hold --allowed --archived --reset --override include|exclude|auto --email-kind template|series --email-id --rule --run --cursor --limit --request-key. Boolean flags take true|false. Complex conditions/actions/mappings use JSON.",
466
+ "CSV: --csv PATH plus --file mapping.json. import start requires a stable --request-key. Retry the same import with the same key and contents.",
467
+ "list/export: --group GROUP_ID --stage STAGE_ID. See package PEOPLE.md for full examples and every JSON shape.",
456
468
  "list: --q --status --source --form-slug --follow-up due|upcoming|none --hidden --sort --last-seen-days --tag --limit --cursor",
457
469
  "add requires --email and/or --phone (never name alone)",
458
470
  "tag: --add <label> | --remove <slug>",
@@ -489,11 +501,11 @@ export const COMMANDS = [
489
501
  "Integer cents only. $49.99 is 4999. Do not send 49.99 or a dollar string. --price-cents is the usual cost. Optional --discount-cents is the sale the buyer pays (strictly less than price). Omit --discount-cents when there is no sale. Checkout charges the sale; the site strikes through the usual price.",
490
502
  "--add / --remove become signed delta. 5 adds 5. --remove 5 removes 5. Result copy is added 5 / removed 5 / set to 20 / stockNow.",
491
503
  "Pass either --add, --remove, or --set. Never two together. --set unlimited returns counted stock to unlimited.",
492
- "items: list|get|create|update|archive|activate [--off] | images --urls … | stock [--variant m] --add|--remove|--set. list --category hats. create/update --categories hats,summer",
504
+ "items: list|get|create|update|archive|activate [--off] | images --urls … | stock [--variant m] --add|--remove|--set. list --category hats. items list --json returns every product and every variant — do not paste that into a model. The editor manage_store list_items call is one page of 25 slim rows plus total; get_item is one product. create/update --categories hats,summer",
493
505
  "categories: list|create|rename|archive. Create the category first. Assigning an unknown slug fails.",
494
506
  "variants: add|update|archive <itemId> --id m --price-cents … [--option Size=M] [--stock]",
495
507
  "settings: get|set-fulfillment|set-currency|set-checkout|set-checkout-fee. Currency is any Stripe presentment code such as usd or jpy. Checkout is store-wide (cart or instant). Do not use leftover items --checkout to pick cart vs Pay now. Gift cards still use --amount open --min-cents. Service fee applies to Add to cart and Pay now. Connect: frontpage store connect|status|disconnect. connect opens Frontpage then Stripe; --no-open for CI. disconnect keeps the shared Stripe account on payments.",
496
- "Page markup is yours: data-fp-store-grid + data-fp-cart (cart starts hidden), every store control data-fp-requires=store. Product pages are the default: unless they said they do not want product pages, write one src/pages/products/item.astro (or shop) with data-fp-store-product. If they said no, skip that file and do not bind href or url. Description is a small markdown subset. PDP bind is <div data-fp-store-bind=description>. Do not bake titles or prices. Guide: https://frontpage.host/developers/store",
508
+ "Page markup is yours: data-fp-store-grid + data-fp-cart (cart starts hidden), every store control data-fp-requires=store. The public catalog pages with offset, limit, total, and truncated (default 24). list_items is the same shape with a max of 25. Product pages are the default: unless they said they do not want product pages, write one src/pages/products/item.astro (or shop) with data-fp-store-product. If they said no, skip that file and do not bind href or url. Description is a small markdown subset. PDP bind is <div data-fp-store-bind=description>. Do not bake titles or prices. Guide: https://frontpage.host/developers/store",
497
509
  "import reads src/content/products/*.json from the site store into Store. One default variant each. Skip if that itemId already exists. Does not invent categories or change fulfillment.",
498
510
  "orders / payments list|get read the same ledgers as frontpage inbox orders|payments",
499
511
  "Unknown leftover flags fail closed (exit 2)",
@@ -502,8 +514,8 @@ export const COMMANDS = [
502
514
  },
503
515
  {
504
516
  name: "mail",
505
- summary: "Site mailings: drafts, templates, series, and sends (not People, not inbox)",
506
- usage: "frontpage mail settings|email|template|series|send|sends|enrolments|suppress … [--site <slug>] [--json]",
517
+ summary: "Emails: campaign drafts, People audiences, templates, series, and sends",
518
+ usage: "frontpage mail settings|email|template|series|campaign|send|sends|enrolments|suppress … [--site <slug>] [--json]",
507
519
  topics: ["content"],
508
520
  examples: [
509
521
  "frontpage mail settings set --postal \"100 Main St\" --reply-to you@example.com",
@@ -514,6 +526,7 @@ export const COMMANDS = [
514
526
  "frontpage mail suppress add --email bounce@example.com",
515
527
  ],
516
528
  flags: [
529
+ "campaign preview|audience|send|get. --id DRAFT_ID, --file audience.json ({audience:{groupIds|stageIds|personIds|filters}}). send requires --request-key; reuse it after a network error. Templates/series are for automation; campaigns have separate drafts. See PEOPLE.md.",
517
530
  "email list is unsent drafts only. Sent one-offs are mail sends list",
518
531
  "send --file is an inline one-off (no saved document)",
519
532
  "Blocks and series steps are JSON (--file). Unknown leftover flags fail closed (exit 2)",
package/llms.txt CHANGED
@@ -30,3 +30,8 @@
30
30
  - Machine: global --json / --quiet; exit 0/2/3/4/5 (see help exit-codes)
31
31
  - deploy wait = agent-run idle (if any) + tip compile ready; it is NOT go-live / publish
32
32
  - No runs continue, domains, billing, members, MCP, or git remotes in v1
33
+
34
+
35
+ ## People groups, stages, rules, and imports
36
+
37
+ Use `frontpage people overview`, `groups`, `stages`, `membership`, `rules`, `attachments`, `import`, and `company`. Use `frontpage mail campaign` for one-off group/stage/list audiences in Emails. Every editor action is available through these commands or `frontpage people workspace OPERATION --file payload.json`. Full operations and examples: [PEOPLE.md](./PEOPLE.md); public guide: https://frontpage.host/developers/people. Automated emails select a saved template or series. Campaigns use their own drafts.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "frontpage-host",
3
- "version": "0.1.22",
4
- "description": "Frontpage CLI — manage your site from the terminal",
3
+ "version": "0.1.24",
4
+ "description": "Frontpage CLI \u2014 manage your site from the terminal",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "frontpage": "dist/bin.js"
@@ -10,7 +10,8 @@
10
10
  "dist",
11
11
  "README.md",
12
12
  "AGENTS.md",
13
- "llms.txt"
13
+ "llms.txt",
14
+ "PEOPLE.md"
14
15
  ],
15
16
  "scripts": {
16
17
  "build": "tsc -p tsconfig.json",
@@ -1,21 +0,0 @@
1
- export type PathFrontpageVerdict = {
2
- stale: boolean;
3
- path: string | null;
4
- pathVersion: string | null;
5
- reason: "older-than-compile-wait" | "older-than-this-package" | "unreadable" | null;
6
- };
7
- export declare function resolveRealPath(path: string): string;
8
- export declare function interpretPathFrontpage(args: {
9
- thisVersion: string;
10
- thisBin: string | null;
11
- pathBin: string | null;
12
- pathVersionLine: string | null;
13
- minVersion?: string;
14
- }): PathFrontpageVerdict;
15
- export declare function whichFrontpage(): string | null;
16
- export declare function frontpageVersionAt(bin: string): string | null;
17
- export declare function inspectPathFrontpage(opts?: {
18
- thisBin?: string | null;
19
- thisVersion?: string;
20
- }): PathFrontpageVerdict;
21
- export declare function stalePathUpgradeMessage(v: PathFrontpageVerdict): string;
package/dist/bin-path.js DELETED
@@ -1,65 +0,0 @@
1
- /**
2
- * Detect a stale `frontpage` on PATH (global 0.1.9 vs this package).
3
- * Does not uninstall anything — doctor prints the uninstall command.
4
- */
5
- import { spawnSync } from "node:child_process";
6
- import { realpathSync } from "node:fs";
7
- import { COMPILE_WAIT_MIN_VERSION, isOlderThan, packageIdentity, parseVersionLine, } from "./version.js";
8
- export function resolveRealPath(path) {
9
- try {
10
- return realpathSync(path);
11
- }
12
- catch {
13
- return path;
14
- }
15
- }
16
- export function interpretPathFrontpage(args) {
17
- if (!args.pathBin) {
18
- return { stale: false, path: null, pathVersion: null, reason: null };
19
- }
20
- if (args.thisBin && resolveRealPath(args.pathBin) === resolveRealPath(args.thisBin)) {
21
- return { stale: false, path: args.pathBin, pathVersion: args.thisVersion, reason: null };
22
- }
23
- const pathVersion = parseVersionLine(args.pathVersionLine ?? "");
24
- if (!pathVersion) {
25
- return { stale: true, path: args.pathBin, pathVersion: null, reason: "unreadable" };
26
- }
27
- const floor = args.minVersion ?? COMPILE_WAIT_MIN_VERSION;
28
- if (isOlderThan(pathVersion, floor)) {
29
- return { stale: true, path: args.pathBin, pathVersion, reason: "older-than-compile-wait" };
30
- }
31
- if (isOlderThan(pathVersion, args.thisVersion)) {
32
- return { stale: true, path: args.pathBin, pathVersion, reason: "older-than-this-package" };
33
- }
34
- return { stale: false, path: args.pathBin, pathVersion, reason: null };
35
- }
36
- export function whichFrontpage() {
37
- const r = spawnSync("sh", ["-c", "command -v frontpage"], { encoding: "utf8" });
38
- const line = (r.stdout ?? "").trim().split("\n")[0]?.trim();
39
- return line || null;
40
- }
41
- export function frontpageVersionAt(bin) {
42
- const r = spawnSync(bin, ["--version"], { encoding: "utf8" });
43
- const text = `${r.stdout ?? ""}\n${r.stderr ?? ""}`;
44
- return parseVersionLine(text);
45
- }
46
- export function inspectPathFrontpage(opts) {
47
- const thisVersion = opts?.thisVersion ?? packageIdentity().version;
48
- const thisBin = opts?.thisBin ?? process.argv[1] ?? null;
49
- const pathBin = whichFrontpage();
50
- const pathVersionLine = pathBin ? frontpageVersionAt(pathBin) : null;
51
- return interpretPathFrontpage({
52
- thisVersion,
53
- thisBin,
54
- pathBin,
55
- pathVersionLine,
56
- });
57
- }
58
- export function stalePathUpgradeMessage(v) {
59
- const found = v.pathVersion ? `frontpage-host ${v.pathVersion}` : "an unknown frontpage-host";
60
- const where = v.path ? ` at ${v.path}` : "";
61
- return (`PATH frontpage is ${found}${where}. Compile-wait needs frontpage-host ${COMPILE_WAIT_MIN_VERSION} or newer. ` +
62
- `Uninstall the stale global (npm uninstall -g frontpage-host) and install ` +
63
- `npm i -g frontpage-host@${COMPILE_WAIT_MIN_VERSION} (or npx frontpage-host@${COMPILE_WAIT_MIN_VERSION}). ` +
64
- `In the Frontpage repo, run node <repo>/cli/dist/bin.js instead of a global binary.`);
65
- }