Day one on a greenfield Salesforce org: standing up Field Service without building on sand
The order I set up Doorvana's Salesforce org — CLI, SFDX project, baseline retrieve, Field Service enablement, the FSL managed package — and what bit me on each.
I am building out a Salesforce org for Doorvana, a garage door manufacturer and installer running three lines of business. The org had just moved up to Enterprise Edition, which is what unblocks Field Service, and it was genuinely greenfield — 261 standard objects and not one custom object.
This is what I did on day one, in the order I did it — CLI, project scaffold, baseline retrieve, Field Service, managed package — and the things that cost me time on each. None of them are exotic. All of them are the kind of thing you only find out by hitting them.
First: get the CLI situation sorted
Before anything else, the toolchain.
An sfdx-cli v7.82.0 from 2020 was still installed from the old macOS package installer,
root-owned at /usr/local/lib/sfdx. Its binary alias collides with @salesforce/cli, so
the modern sf command will not install cleanly over the top of it. The old one has to come
off with sudo first.
Five minutes once you know. Considerably longer when the error is telling you something about permissions and you are still reading it as a Node problem.
Scaffolding the SFDX project
sf project generate gives you the skeleton in a second. The part worth thinking about is
the four files it leaves you to fill in, because each one is a decision you will live with.
sfdx-project.json
sfdx-project.json
{
"packageDirectories": [
{
"path": "force-app",
"default": true
}
],
"name": "doorvana",
"namespace": "",
"sfdcLoginUrl": "https://<your-my-domain>.my.salesforce.com",
"sourceApiVersion": "67.0"
} Two fields here matter more than they look.
sfdcLoginUrl should point at the org’s My Domain, not login.salesforce.com. Once My
Domain is enforced — and it is, on any modern org — the generic login host stops being the
right answer and you get an authentication failure that reads like a credentials problem.
sourceApiVersion I set provisionally and flagged to verify against the org on first
connect. A source API version ahead of what the org actually runs fails deploys in ways
that do not name the real cause. It is a one-line check and it saves an afternoon.
.gitignore — before the first commit, not after
.gitignore
.sf/
.sfdx/
node_modules/
.DS_Store
# Client meeting artifacts — a 5.5 GB discovery recording and screenshots.
# Local only: too large for git, and they contain customer detail.
Meeting Artifacts/ That middle entry is the one I would press on. The discovery recording for this project is 5.5 GB. Beyond simply not belonging in git, client recordings and screenshots routinely contain customer names, addresses and pricing. Decide where those live before the first commit, because getting a large file with customer data back out of git history is a bad afternoon and an awkward conversation.
config/project-scratch-def.json
config/project-scratch-def.json
{
"orgName": "Doorvana",
"edition": "Enterprise",
"features": ["FieldService", "ServiceCloud", "SalesCloud", "Sites"],
"settings": {
"lightningExperienceSettings": { "enableS1DesktopEnabled": true }
}
} Worth writing even if you are not using scratch orgs yet. The features array is the
shortest honest statement of what this org has to be, and it is checked into the repo where
the next person can read it — rather than living in someone’s memory of a licensing call.
manifest/package.xml
The manifest drives every retrieve, which makes it the source of truth for what “the org” means to this repo. Mine lists the usual types with a wildcard member each: objects, fields, record types, layouts, permission sets, profiles, flows, Apex, tabs, apps, FlexiPages.
That list is also where the first real mistake of the project was hiding — more on that in a moment.
One fork worth calling out: profiles in source, or not?
The default instinct is to .forceignore profiles. They are noisy, they diff badly, and
everyone has been burned by them.
I track them anyway on this project, because an org where permissions change and nobody can see the change is exactly the rot I am trying to prevent. That is a real choice with a real cost attached, and the cost shows up two sections down.
Snapshot the org before you touch it
The first thing into git was not a change. It was the org exactly as it already was.
A baseline retrieve gives you the thing that makes everything afterward reviewable: a diff. Without it, six weeks in, nobody can tell which layout you edited and which one was always like that. With it, every later commit is a specific, readable change.
The snapshot came back as 228 components — 40 Apex classes, 98 layouts, 29 apps, 26 FlexiPages, 24 profiles, 7 permission sets, 2 flows and a tab. All stock. Zero custom objects confirmed what the client had told me: nothing had been built here yet.
Your “complete” baseline retrieve is not complete
Here is the one that actually bit me.
A package.xml with <members>*</members> feels exhaustive. It is not. The wildcard
applies within each metadata type you listed — a type you did not list is invisible,
however many components of it exist in the org.
My first manifest covered the obvious types: objects, fields, record types, layouts,
profiles, permission sets, flows, Apex, tabs, apps, FlexiPages. It did not list
GlobalValueSet. So seven global value sets that already existed in the org simply did not
appear, and I spent the first hour believing the org had none.
manifest/package.xml
<types>
<members>*</members>
<name>GlobalValueSet</name>
</types>
<types>
<members>*</members>
<name>StandardValueSet</name>
</types> What surfaced once those two entries went in:
| Global value set | Values |
|---|---|
| MSA | 29 |
| Property_Type | 22 |
| Garage_Door_Brand | 22 |
| Operator_Brand | 16 |
| Property_Status | 8 |
| Garage_Door_Material | 5 |
| PMOC | 4 |
This was not housekeeping. Property_Status turned out to read as the entire new
construction lifecycle — Under Construction, Model Home, Finished Vacant, Owner-Occupied —
which maps directly onto the property records I was about to design. And MSA sitting at
29 values, rather than the hundreds I had assumed, changed a data model decision I had
already half made.
Metadata I could not see was about to make me design the wrong thing.
Profiles retrieve sparsely, and that is the cost of tracking them
Here is the bill for that choice.
A retrieved profile only carries permissions for components that were part of that same retrieve. Pull a profile alongside two objects and you get a profile file describing permissions for exactly two objects — not a copy of the profile. Deploy that file back and it can silently drop everything it did not mention.
The rule that falls out of it: profiles are retrieved with the full manifest or not at all. Never as part of a narrow, targeted retrieve.
Enabling Field Service is two steps, not one
This is the part I would most want someone to tell me.
“Turn on Field Service” is actually three separate layers, and only the middle one is the switch in Setup.
The trap is layer one. WorkOrder and WorkOrderLineItem are standard Service Cloud
objects. They were sitting in the org before Field Service was enabled, and if you check
for them you will conclude Field Service is on. It is not. On this org ServiceAppointment,
AssignedResource and ServiceTerritory did not exist yet.
Check for ServiceAppointment. That is the one that tells you the truth.
Layer three is a separate install with its own version — here, the FSL namespace at
v262.0.68.1. Worth writing the version down somewhere durable; “we’re on the latest” ages
badly.
Verify enablement with a script, not by squinting at Setup
Rather than clicking through Setup and hoping, I wrote a short check that asks the org directly and prints the whole chain. It takes an org alias so it can run against production or a sandbox.
scripts/check-fsl.sh
#!/usr/bin/env bash
# Verify Field Service enablement + FSL managed package install.
set -euo pipefail
ORG="${1:-doorvana-prod}"
echo "=== Field Service object chain ($ORG) ==="
sf org list metadata --metadata-type CustomObject --target-org "$ORG" --json \
| python3 -c "
import json,sys
names={x['fullName'] for x in (json.load(sys.stdin).get('result') or [])}
chain=['WorkOrder','WorkOrderLineItem','ServiceAppointment','AssignedResource',
'ServiceResource','ServiceTerritory','ServiceTerritoryMember',
'OperatingHours','TimeSlot','ResourceAbsence','ProductConsumed','Expense']
missing=[o for o in chain if o not in names]
for o in chain:
print((' YES ' if o in names else ' MISS ')+o)
print()
print('Field Service ENABLED' if not missing else f'INCOMPLETE — missing {len(missing)}: '+', '.join(missing))
"
echo
echo "=== FSL managed package ==="
sf package installed list --target-org "$ORG" 2>/dev/null \
| grep -i 'field service\|FSL' || echo " FSL managed package NOT installed" Two minutes to write, and it has already earned its keep — every time I wonder whether a deploy landed on the right org, I run it instead of guessing.
Ignore the managed package — but not with a bare wildcard
Namespaced components belong to the package, not to you. They should not be in your repo,
and .forceignore is where you say so.
The tempting pattern is *__*, because managed components are namespaced with a double
underscore. Do not use it. That glob also matches every ordinary custom object you create —
Work_Order_Detail__c and friends — so it would quietly stop tracking the work you actually
own. Name the namespace instead.
.forceignore
# Managed package metadata — namespaced components are owned by the package,
# not by this repo. (Do NOT use a bare *__* glob here: it would also match
# ordinary custom objects like Work_Order_Detail__c.)
**/FSL__*
**/fsl__* On sandboxes
Enterprise Edition is also what gets you sandboxes — the previous edition had none at all, which is a constraint worth naming out loud when you are scoping an upgrade, because it means there was never anywhere safe to test.
Anything structural gets tried there first. Later the same week I had to decide between two ways of modelling a job-site address, and rather than reason about which was cheaper I ran both against the sandbox and counted: one was a single DML on a single record, the other was three DMLs across two records with a circular reference that made cleanup a three-step operation. That is not an argument I would have won from the documentation.
Budget for it, though. Creating and refreshing a sandbox is not instant, and it is not the thing you want to discover on the morning you need one.
The order that worked
- Remove the old CLI before installing
@salesforce/cli - Scaffold the SFDX project — and point
sfdcLoginUrlat the org’s My Domain - Write
.gitignorebefore the first commit, with client recordings excluded - Verify
sourceApiVersionagainst the org rather than trusting the default - Decide the profiles question deliberately, knowing what it costs
- Baseline retrieve the untouched org into git
- Reconcile the manifest against the metadata types the org actually has, and retrieve again
- Enable Field Service in Setup
- Install the Field Service managed package and record its version
- Verify the object chain with a script, against the org, not the UI
.forceignorethe package namespace — specifically, not with a wildcard- Only now start building
Steps 6 and 7 are the ones people skip, and they are the two that decide whether the next three months are reviewable. Getting a manifest wrong is not just a missing file — on this org it nearly cost me a data model decision, made confidently, on incomplete information.
If you are standing up Field Service on a greenfield org and want to compare notes, the services page says how I work, or just get in touch.