@anyday/sanity-workspace-manager 0.1.0 → 0.2.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.
- package/README.md +149 -31
- package/dist/cli.mjs +87 -15
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,51 +1,169 @@
|
|
|
1
1
|
# @anyday/sanity-workspace-manager
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
The command-line tool for Anyday's Sanity workspace manager. The workspace manager creates a new
|
|
4
|
+
Sanity workspace for a site in a few clicks: the project, its dataset, starter or copied content,
|
|
5
|
+
the brand, the central Studio entry and the Vercel site. It holds no code for any one project.
|
|
6
|
+
Each kind of site (a **project type**) is described instead by a `workspace-manager.json` manifest
|
|
7
|
+
that lives in that site's repo.
|
|
8
|
+
|
|
9
|
+
This tool writes and saves that manifest:
|
|
10
|
+
|
|
11
|
+
- **`init`** drafts `workspace-manager.json` from the site repo, its Sanity schema and, optionally,
|
|
12
|
+
the live Sanity project, then lists what it guessed so you can review it.
|
|
13
|
+
- **`register`** checks the manifest and saves it to the workspace manager, which then offers the
|
|
14
|
+
project type in the Workspaces app.
|
|
15
|
+
|
|
16
|
+
## Requirements
|
|
17
|
+
|
|
18
|
+
- Node.js 20 or later.
|
|
19
|
+
- A Sanity login: run `npx sanity login` once, or set `SANITY_AUTH_TOKEN`.
|
|
20
|
+
- For `register`, the **Administrator** role in the manifest's Sanity organisation.
|
|
21
|
+
|
|
22
|
+
## Quick start
|
|
23
|
+
|
|
24
|
+
From the root of the site repo:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
# 1. Export the Studio's schema (writes sanity.schema.json)
|
|
28
|
+
cd studio && npx sanity schema extract && cd ..
|
|
29
|
+
|
|
30
|
+
# 2. Draft the manifest from the repo and the live project; it lists the site's pages to pick
|
|
31
|
+
# starter content from
|
|
32
|
+
npx @anyday/sanity-workspace-manager init --live
|
|
33
|
+
|
|
34
|
+
# 3. Review workspace-manager.json, then commit it next to the schema
|
|
35
|
+
|
|
36
|
+
# 4. Save it to the workspace manager
|
|
37
|
+
npx @anyday/sanity-workspace-manager register --api https://anyday-workspace-manager-sanity-api.vercel.app
|
|
38
|
+
```
|
|
5
39
|
|
|
6
40
|
## init
|
|
7
41
|
|
|
8
42
|
```bash
|
|
9
|
-
|
|
10
|
-
|
|
43
|
+
npx @anyday/sanity-workspace-manager init [dir] [options]
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`dir` is the site repo, and defaults to the current folder. `init` asks four questions, each with a
|
|
47
|
+
suggestion you can accept with Enter:
|
|
48
|
+
|
|
49
|
+
| Question | Suggested from |
|
|
50
|
+
| ------------------------------------------- | ------------------------------------------------------------------ |
|
|
51
|
+
| Project type id, short and lowercase | The repo name in the git remote, e.g. `acme-site` |
|
|
52
|
+
| Name shown in the app | The same repo name, capitalised |
|
|
53
|
+
| Sanity project to copy schemas from | A `projectId: '…'` written in the Studio's config, if there is one |
|
|
54
|
+
| Sanity organisation to create workspaces in | Looked up from that project with your login |
|
|
55
|
+
|
|
56
|
+
The id is permanent: jobs store it and it prefixes each site's Vercel project. Registering a
|
|
57
|
+
different id later creates a second project type instead of updating this one.
|
|
11
58
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
59
|
+
### What it reads
|
|
60
|
+
|
|
61
|
+
| Source | Used for |
|
|
62
|
+
| ------------------------------- | ---------------------------------------------------------------------------------------------------- |
|
|
63
|
+
| git remote and default branch | `site.repo`, `site.branch`, the suggested id and name |
|
|
64
|
+
| `.env.example`, names only | `siteEnv`, the variables each new site needs |
|
|
65
|
+
| `sanity.schema.json` | copy sections, singletons, home page, language documents, settings fields, colour and font templates |
|
|
66
|
+
| the live project, with `--live` | theme defaults, colour defaults and starter pages |
|
|
67
|
+
|
|
68
|
+
`sanity.schema.json` is looked for in the repo root, `frontend`, `web`, `site`, `studio`, `apps`
|
|
69
|
+
and each folder in `apps`.
|
|
70
|
+
|
|
71
|
+
### Options
|
|
72
|
+
|
|
73
|
+
| Option | Does |
|
|
74
|
+
| ------------------- | ----------------------------------------------------------------------------------------------- |
|
|
75
|
+
| `--yes` | Accepts the suggested id, name and project without asking |
|
|
76
|
+
| `--project <id>` | The Sanity project new workspaces copy their schema from |
|
|
77
|
+
| `--org <id>` | The Sanity organisation new workspaces are created in (found with `--live`) |
|
|
78
|
+
| `--live` | Reads the current theme, settings and starter pages from that project |
|
|
79
|
+
| `--dataset <name>` | The dataset `--live` reads (default `production`) |
|
|
80
|
+
| `--starter <pages>` | Starter pages without the list: `home`, slugs or document ids, comma-separated (needs `--live`) |
|
|
81
|
+
| `--force` | Replaces an existing `workspace-manager.json` |
|
|
82
|
+
| `--print` | Prints the draft instead of writing it |
|
|
83
|
+
|
|
84
|
+
### Starter pages
|
|
85
|
+
|
|
86
|
+
Starter pages are what a new workspace starts with when it doesn't copy another workspace. With
|
|
87
|
+
`--live`, `init` lists the live site's pages in the home page's language, home first:
|
|
88
|
+
|
|
89
|
+
```text
|
|
90
|
+
Pages of the live site:
|
|
91
|
+
1. Home (/home) ← home
|
|
92
|
+
2. About Olive (/about-olive)
|
|
93
|
+
3. Accessibility (/accessibility)
|
|
94
|
+
Starter pages, by number (e.g. 1,3; Enter for one empty home page):
|
|
15
95
|
```
|
|
16
96
|
|
|
17
|
-
|
|
97
|
+
Pick by number, or press Enter for one empty home page. To skip the list, name the pages with
|
|
98
|
+
`--starter`: `home` means whichever page the settings mark as the home page, and any other entry
|
|
99
|
+
is a page's slug or document id, e.g. `--starter home,about-olive`. When translations share a
|
|
100
|
+
slug, the home page's language wins. With `--yes` and no `--starter`, the starter is one empty
|
|
101
|
+
home page.
|
|
18
102
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
| git remote and default branch | `site.repo`, `site.branch`, the suggested id and title |
|
|
22
|
-
| `.env.example` (names only) | `siteEnv` |
|
|
23
|
-
| `sanity.schema.json` | copy sections, singletons, home page, language documents, settings fields, colour and font templates |
|
|
24
|
-
| the live project (`--live`) | theme defaults, colour defaults, starter pages (`--starter <ids>`) |
|
|
103
|
+
Starter pages get `{{siteName}}` and `{{language}}` where the source site's name and language
|
|
104
|
+
were. Font files are never copied into theme defaults, since fonts are often licensed per brand.
|
|
25
105
|
|
|
26
|
-
|
|
27
|
-
get `{{siteName}}` and `{{language}}` where the source site's own name and language were.
|
|
106
|
+
### Review before registering
|
|
28
107
|
|
|
29
|
-
The draft is checked against the manifest
|
|
30
|
-
afterwards
|
|
31
|
-
|
|
108
|
+
The draft is checked against the manifest's JSON Schema before it is written, and every guess is
|
|
109
|
+
listed afterwards. Check these in particular:
|
|
110
|
+
|
|
111
|
+
- **Copy sections** start as every content type. Keep only the ones editors should be able to copy
|
|
112
|
+
between workspaces.
|
|
113
|
+
- **Starter pages** must not link to documents outside the starter: a new workspace won't have
|
|
114
|
+
them, and saving the page fails. `init` names any such links.
|
|
115
|
+
- **Colour roles** come with empty descriptions. Add a short one each, saying where the colour is
|
|
116
|
+
used, so whoever sets up a brand knows what they are picking.
|
|
117
|
+
- **Language documents and singletons** are guessed from type names (`…Settings`, `l10n.locale`).
|
|
32
118
|
|
|
33
119
|
## register
|
|
34
120
|
|
|
35
121
|
```bash
|
|
36
|
-
|
|
37
|
-
npx @anyday/sanity-workspace-manager register --api https://anyday-workspace-manager-sanity-api.vercel.app
|
|
122
|
+
npx @anyday/sanity-workspace-manager register [dir] --api <url>
|
|
38
123
|
```
|
|
39
124
|
|
|
40
|
-
Checks
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
`
|
|
125
|
+
Checks `workspace-manager.json` in `dir` (default: the current folder) and saves it to the
|
|
126
|
+
workspace manager. The live API is `https://anyday-workspace-manager-sanity-api.vercel.app`; set
|
|
127
|
+
`WORKSPACE_MANAGER_API_URL` to skip `--api`.
|
|
128
|
+
|
|
129
|
+
- You must be an **Administrator** of the manifest's `organizationId`.
|
|
130
|
+
- Registering again replaces the saved copy for that `id`, so a changed manifest needs no delete
|
|
131
|
+
first.
|
|
132
|
+
- It prints `Registered <name> (<id>)` when saved.
|
|
133
|
+
|
|
134
|
+
## Signing in
|
|
135
|
+
|
|
136
|
+
Both commands use `SANITY_AUTH_TOKEN` when it is set, and otherwise the token `npx sanity login`
|
|
137
|
+
saved. Use your own login: the workspace manager refuses robot tokens for `register`, so that
|
|
138
|
+
every project type traces back to a person. If a robot token is exported in your shell (for
|
|
139
|
+
example for a Studio deploy), `unset SANITY_AUTH_TOKEN` before registering.
|
|
140
|
+
|
|
141
|
+
## Troubleshooting
|
|
142
|
+
|
|
143
|
+
| Message | Fix |
|
|
144
|
+
| ----------------------------------------------------- | ------------------------------------------------------------------ |
|
|
145
|
+
| `No sanity.schema.json` | Run `npx sanity schema extract` in the Studio folder |
|
|
146
|
+
| `workspace-manager.json exists: pass --force` | Add `--force` to replace it, or `--print` to compare |
|
|
147
|
+
| `--starter needs --live` | Add `--live`; starter pages are read from the live project |
|
|
148
|
+
| `Pass --api <url> or set WORKSPACE_MANAGER_API_URL` | Add `--api https://anyday-workspace-manager-sanity-api.vercel.app` |
|
|
149
|
+
| `Use your own Sanity login, not a robot token` | `unset SANITY_AUTH_TOKEN` and use `npx sanity login` |
|
|
150
|
+
| `Register only as an Administrator of organisation …` | Ask an Administrator of that organisation to run `register` |
|
|
151
|
+
|
|
152
|
+
## How it fits together
|
|
153
|
+
|
|
154
|
+
The full design, including every manifest field, is in
|
|
155
|
+
[docs/project-config.md](https://github.com/anydayagency/anyday-workspace-manager-sanity/blob/main/docs/project-config.md),
|
|
156
|
+
and an overview in
|
|
157
|
+
[docs/how-it-works.md](https://github.com/anydayagency/anyday-workspace-manager-sanity/blob/main/docs/how-it-works.md)
|
|
158
|
+
(Anyday's GitHub organisation only).
|
|
159
|
+
|
|
160
|
+
## Developing and publishing
|
|
45
161
|
|
|
46
|
-
|
|
162
|
+
From a clone of the workspace manager repo, run the CLI without npm:
|
|
163
|
+
`npm run cli -- init ../my-site`. `npm run build` bundles `src/cli.ts`, with the manifest code it
|
|
164
|
+
borrows from the API, into `dist/cli.mjs`; only `@sanity/client` and `ajv` stay dependencies.
|
|
47
165
|
|
|
48
|
-
|
|
49
|
-
`
|
|
50
|
-
|
|
51
|
-
|
|
166
|
+
Publish from `main`: bump `version`, then run
|
|
167
|
+
`npm publish --workspace=@anyday/sanity-workspace-manager --access public --auth-type=web` from the
|
|
168
|
+
repo root. It builds and tests first; the web sign-in covers npm's two-factor check, and the
|
|
169
|
+
release then waits for approval on npmjs.com.
|
package/dist/cli.mjs
CHANGED
|
@@ -642,7 +642,7 @@ function draftStarter(schema, home, live, note) {
|
|
|
642
642
|
const page = { _id: "starter-home", _type: type };
|
|
643
643
|
if ("title" in fields) page.title = "{{siteName}}";
|
|
644
644
|
if ("language" in fields) page.language = "{{language}}";
|
|
645
|
-
note("Starter is one empty home page:
|
|
645
|
+
note("Starter is one empty home page: run with --live to pick real pages instead");
|
|
646
646
|
return { homeId: "starter-home", documents: [page] };
|
|
647
647
|
}
|
|
648
648
|
function draftBrand(schema, home, live, note) {
|
|
@@ -792,15 +792,27 @@ async function organizationOf(projectId, token) {
|
|
|
792
792
|
if (!response.ok) throw new Error(`Couldn't read project ${projectId}: ${response.status}`);
|
|
793
793
|
return (await response.json()).organizationId ?? "";
|
|
794
794
|
}
|
|
795
|
+
var liveClient = ({ projectId, dataset, token }) => createClient({
|
|
796
|
+
projectId,
|
|
797
|
+
dataset,
|
|
798
|
+
token,
|
|
799
|
+
apiVersion: "2026-08-15",
|
|
800
|
+
useCdn: false,
|
|
801
|
+
perspective: "published"
|
|
802
|
+
});
|
|
803
|
+
async function listPages(options) {
|
|
804
|
+
const { settings, pages } = await liveClient(options).fetch(
|
|
805
|
+
`{
|
|
806
|
+
"settings": *[_id == $settingsId][0],
|
|
807
|
+
"pages": *[_type == $type && defined(slug.current)]{_id, title, "slug": slug.current, language}
|
|
808
|
+
}`,
|
|
809
|
+
{ settingsId: options.home.documentId, type: options.pageType }
|
|
810
|
+
);
|
|
811
|
+
const homeId = settings?.[options.home.field]?._ref ?? null;
|
|
812
|
+
return { pages, homeId };
|
|
813
|
+
}
|
|
795
814
|
async function readLive(options) {
|
|
796
|
-
const client =
|
|
797
|
-
projectId: options.projectId,
|
|
798
|
-
dataset: options.dataset,
|
|
799
|
-
token: options.token,
|
|
800
|
-
apiVersion: "2026-08-15",
|
|
801
|
-
useCdn: false,
|
|
802
|
-
perspective: "published"
|
|
803
|
-
});
|
|
815
|
+
const client = liveClient(options);
|
|
804
816
|
const docs = await client.fetch("*[_id in $ids]", {
|
|
805
817
|
ids: [options.settingsId, options.themeId, ...options.starterIds]
|
|
806
818
|
});
|
|
@@ -861,6 +873,39 @@ function readRepo(root) {
|
|
|
861
873
|
};
|
|
862
874
|
}
|
|
863
875
|
|
|
876
|
+
// src/init/starter.ts
|
|
877
|
+
function resolveStarter(entries, pages, homeId) {
|
|
878
|
+
const home = pages.find((page) => page._id === homeId);
|
|
879
|
+
return entries.map((entry) => {
|
|
880
|
+
if (entry === "home") {
|
|
881
|
+
if (!homeId) throw new Error("The live settings name no home page: pass its slug instead");
|
|
882
|
+
return homeId;
|
|
883
|
+
}
|
|
884
|
+
if (pages.some((page) => page._id === entry)) return entry;
|
|
885
|
+
let matches = pages.filter((page) => page.slug === entry);
|
|
886
|
+
if (matches.length > 1 && home?.language)
|
|
887
|
+
matches = matches.filter((page) => page.language === home.language);
|
|
888
|
+
if (matches.length === 1) return matches[0]._id;
|
|
889
|
+
if (!matches.length) throw new Error(`No page with the slug or id "${entry}"`);
|
|
890
|
+
throw new Error(
|
|
891
|
+
`Several pages have the slug "${entry}": pass one of ${matches.map((page) => page._id).join(", ")}`
|
|
892
|
+
);
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
function pickable(pages, homeId) {
|
|
896
|
+
const home = pages.find((page) => page._id === homeId);
|
|
897
|
+
const shown = home?.language ? pages.filter((page) => page.language === home.language) : pages;
|
|
898
|
+
return [...shown].sort(
|
|
899
|
+
(a, b) => a._id === homeId ? -1 : b._id === homeId ? 1 : (a.title ?? "").localeCompare(b.title ?? "")
|
|
900
|
+
);
|
|
901
|
+
}
|
|
902
|
+
function parsePick(answer, count) {
|
|
903
|
+
const picked = answer.split(/[\s,]+/).filter(Boolean).map((part) => Number(part));
|
|
904
|
+
const wrong = picked.filter((n) => !Number.isInteger(n) || n < 1 || n > count);
|
|
905
|
+
if (wrong.length) throw new Error(`Pick numbers from 1 to ${count}`);
|
|
906
|
+
return [...new Set(picked)].map((n) => n - 1);
|
|
907
|
+
}
|
|
908
|
+
|
|
864
909
|
// src/register.ts
|
|
865
910
|
import { readFileSync as readFileSync3 } from "node:fs";
|
|
866
911
|
import { join as join3 } from "node:path";
|
|
@@ -896,7 +941,8 @@ Options:
|
|
|
896
941
|
--org <id> The Sanity organisation new workspaces are created in (found with --live)
|
|
897
942
|
--live Read the current theme, settings and starter pages from that project
|
|
898
943
|
--dataset <name> Dataset to read with --live (default: production)
|
|
899
|
-
--starter <
|
|
944
|
+
--starter <pages> Comma-separated pages to copy as starter content: \`home\`, slugs or
|
|
945
|
+
document ids (needs --live; without it, --live offers a list to pick from)
|
|
900
946
|
--force Overwrite an existing workspace-manager.json
|
|
901
947
|
--print Print the draft instead of writing it
|
|
902
948
|
|
|
@@ -957,13 +1003,22 @@ async function init(argv) {
|
|
|
957
1003
|
};
|
|
958
1004
|
let draft = draftManifest(repo, base);
|
|
959
1005
|
if (token) {
|
|
1006
|
+
const project = { projectId: answers.project, dataset: values.dataset, token };
|
|
1007
|
+
const { pages, homeId } = await listPages({
|
|
1008
|
+
...project,
|
|
1009
|
+
pageType: draft.manifest.content.starter.documents[0]._type,
|
|
1010
|
+
home: draft.manifest.content.homePage
|
|
1011
|
+
});
|
|
1012
|
+
const starterIds = values.starter ? resolveStarter(
|
|
1013
|
+
values.starter.split(",").map((entry) => entry.trim()).filter(Boolean),
|
|
1014
|
+
pages,
|
|
1015
|
+
homeId
|
|
1016
|
+
) : values.yes ? [] : await pickStarter(pickable(pages, homeId), homeId);
|
|
960
1017
|
const live = await readLive({
|
|
961
|
-
|
|
962
|
-
dataset: values.dataset,
|
|
963
|
-
token,
|
|
1018
|
+
...project,
|
|
964
1019
|
settingsId: draft.manifest.brand.settings.documentId,
|
|
965
1020
|
themeId: draft.manifest.brand.theme.documentId,
|
|
966
|
-
starterIds
|
|
1021
|
+
starterIds
|
|
967
1022
|
});
|
|
968
1023
|
draft = draftManifest(repo, base, live);
|
|
969
1024
|
}
|
|
@@ -978,7 +1033,7 @@ Check before registering:`);
|
|
|
978
1033
|
for (const note of draft.notes) console.error(` - ${note}`);
|
|
979
1034
|
}
|
|
980
1035
|
async function ask(suggested, orgFor) {
|
|
981
|
-
const prompt = createInterface({ input: process.stdin, output: process.
|
|
1036
|
+
const prompt = createInterface({ input: process.stdin, output: process.stderr });
|
|
982
1037
|
const question = async (label, fallback) => (await prompt.question(`${label}${fallback ? ` (${fallback})` : ""}: `)).trim() || fallback;
|
|
983
1038
|
try {
|
|
984
1039
|
const id = await question("Project type id, short and lowercase", suggested.id);
|
|
@@ -990,6 +1045,23 @@ async function ask(suggested, orgFor) {
|
|
|
990
1045
|
prompt.close();
|
|
991
1046
|
}
|
|
992
1047
|
}
|
|
1048
|
+
async function pickStarter(pages, homeId) {
|
|
1049
|
+
if (!pages.length) return [];
|
|
1050
|
+
console.error("\nPages of the live site:");
|
|
1051
|
+
pages.forEach((page, index) => {
|
|
1052
|
+
const label = `${page.title ?? page._id} (/${page.slug})`;
|
|
1053
|
+
console.error(` ${index + 1}. ${label}${page._id === homeId ? " \u2190 home" : ""}`);
|
|
1054
|
+
});
|
|
1055
|
+
const prompt = createInterface({ input: process.stdin, output: process.stderr });
|
|
1056
|
+
try {
|
|
1057
|
+
const answer = await prompt.question(
|
|
1058
|
+
"Starter pages, by number (e.g. 1,3; Enter for one empty home page): "
|
|
1059
|
+
);
|
|
1060
|
+
return parsePick(answer, pages.length).map((index) => pages[index]._id);
|
|
1061
|
+
} finally {
|
|
1062
|
+
prompt.close();
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
993
1065
|
var commands = {
|
|
994
1066
|
init,
|
|
995
1067
|
register: registerCommand
|