@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.
Files changed (3) hide show
  1. package/README.md +149 -31
  2. package/dist/cli.mjs +87 -15
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,51 +1,169 @@
1
1
  # @anyday/sanity-workspace-manager
2
2
 
3
- Drafts a site repo's `workspace-manager.json`, the manifest that tells the workspace manager how
4
- to set up sites for that project type. See [docs/project-config.md](https://github.com/anydayagency/anyday-workspace-manager-sanity/blob/main/docs/project-config.md).
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
- # in the site repo's Studio: writes sanity.schema.json
10
- npx sanity schema extract
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
- # in the site repo: asks for the id, title and schema project
13
- npx @anyday/sanity-workspace-manager init
14
- npx @anyday/sanity-workspace-manager init --live --starter home,about,contact
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
- From a clone of this repo, without npm: `npm run cli -- init ../my-site`.
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
- | Reads | For |
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
- `--live` signs in with `SANITY_AUTH_TOKEN`, or the token `npx sanity login` saved. Starter pages
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 schema before it is written. Every guess is listed
30
- afterwards: review them, in particular the copy sections, which start as every content type.
31
- `--print` shows the draft without writing it.
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
- # in the site repo, once its workspace-manager.json is reviewed and committed
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 the manifest, then saves it through the API signed in as you (`SANITY_AUTH_TOKEN` or
41
- `npx sanity login`). You must be an Administrator of the manifest's `organizationId`.
42
- Registering again replaces the saved copy for that `id`; a different `id` adds a new project type.
43
- Set `WORKSPACE_MANAGER_API_URL` to skip `--api`; locally, `npm run dev:api` serves it on
44
- `http://localhost:3100`.
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
- ## Publishing
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
- `npm run build` bundles `src/cli.ts` and the manifest code it borrows from the API into
49
- `dist/cli.mjs`; only `@sanity/client` and `ajv` stay dependencies. Bump `version`, then from
50
- this folder run `npm publish --access public --auth-type=web` (it builds and tests first; the
51
- web sign-in covers npm's two-factor check).
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: pass --starter <ids> with --live to copy real pages");
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 = createClient({
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 <ids> Comma-separated document ids to use as starter content (needs --live)
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
- projectId: answers.project,
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: values.starter?.split(",").map((id) => id.trim()).filter(Boolean) ?? []
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.stdout });
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anyday/sanity-workspace-manager",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Drafts a site repo's workspace-manager.json for the Anyday workspace manager",
5
5
  "repository": {
6
6
  "type": "git",