@anyday/sanity-workspace-manager 0.0.0-stage → 0.1.1

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 -2
  2. package/dist/cli.mjs +1006 -0
  3. package/package.json +39 -4
package/README.md CHANGED
@@ -1,3 +1,150 @@
1
- # Temporary Holding Version
1
+ # @anyday/sanity-workspace-manager
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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, reading theme defaults and three starter pages from the live project
31
+ npx @anyday/sanity-workspace-manager init --live --starter home,about,contact
32
+
33
+ # 3. Review workspace-manager.json, then commit it next to the schema
34
+
35
+ # 4. Save it to the workspace manager
36
+ npx @anyday/sanity-workspace-manager register --api https://anyday-workspace-manager-sanity-api.vercel.app
37
+ ```
38
+
39
+ ## init
40
+
41
+ ```bash
42
+ npx @anyday/sanity-workspace-manager init [dir] [options]
43
+ ```
44
+
45
+ `dir` is the site repo, and defaults to the current folder. `init` asks four questions, each with a
46
+ suggestion you can accept with Enter:
47
+
48
+ | Question | Suggested from |
49
+ | ------------------------------------------- | ------------------------------------------------------------------ |
50
+ | Project type id, short and lowercase | The repo name in the git remote, e.g. `acme-site` |
51
+ | Name shown in the app | The same repo name, capitalised |
52
+ | Sanity project to copy schemas from | A `projectId: '…'` written in the Studio's config, if there is one |
53
+ | Sanity organisation to create workspaces in | Looked up from that project with your login |
54
+
55
+ The id is permanent: jobs store it and it prefixes each site's Vercel project. Registering a
56
+ different id later creates a second project type instead of updating this one.
57
+
58
+ ### What it reads
59
+
60
+ | Source | Used for |
61
+ | ------------------------------- | ---------------------------------------------------------------------------------------------------- |
62
+ | git remote and default branch | `site.repo`, `site.branch`, the suggested id and name |
63
+ | `.env.example`, names only | `siteEnv`, the variables each new site needs |
64
+ | `sanity.schema.json` | copy sections, singletons, home page, language documents, settings fields, colour and font templates |
65
+ | the live project, with `--live` | theme defaults, colour defaults and starter pages (`--starter <ids>`) |
66
+
67
+ `sanity.schema.json` is looked for in the repo root, `frontend`, `web`, `site`, `studio`, `apps`
68
+ and each folder in `apps`.
69
+
70
+ ### Options
71
+
72
+ | Option | Does |
73
+ | ------------------ | --------------------------------------------------------------------------- |
74
+ | `--yes` | Accepts the suggested id, name and project without asking |
75
+ | `--project <id>` | The Sanity project new workspaces copy their schema from |
76
+ | `--org <id>` | The Sanity organisation new workspaces are created in (found with `--live`) |
77
+ | `--live` | Reads the current theme, settings and starter pages from that project |
78
+ | `--dataset <name>` | The dataset `--live` reads (default `production`) |
79
+ | `--starter <ids>` | Comma-separated document ids to use as starter content (needs `--live`) |
80
+ | `--force` | Replaces an existing `workspace-manager.json` |
81
+ | `--print` | Prints the draft instead of writing it |
82
+
83
+ Without `--starter`, new workspaces start from one empty home page. Starter pages get
84
+ `{{siteName}}` and `{{language}}` where the source site's name and language were. Font files are
85
+ never copied into theme defaults, since fonts are often licensed per brand.
86
+
87
+ ### Review before registering
88
+
89
+ The draft is checked against the manifest's JSON Schema before it is written, and every guess is
90
+ listed afterwards. Check these in particular:
91
+
92
+ - **Copy sections** start as every content type. Keep only the ones editors should be able to copy
93
+ between workspaces.
94
+ - **Starter pages** must not link to documents outside the starter: a new workspace won't have
95
+ them, and saving the page fails. `init` names any such links.
96
+ - **Colour roles** come with empty descriptions. Add a short one each, saying where the colour is
97
+ used, so whoever sets up a brand knows what they are picking.
98
+ - **Language documents and singletons** are guessed from type names (`…Settings`, `l10n.locale`).
99
+
100
+ ## register
101
+
102
+ ```bash
103
+ npx @anyday/sanity-workspace-manager register [dir] --api <url>
104
+ ```
105
+
106
+ Checks `workspace-manager.json` in `dir` (default: the current folder) and saves it to the
107
+ workspace manager. The live API is `https://anyday-workspace-manager-sanity-api.vercel.app`; set
108
+ `WORKSPACE_MANAGER_API_URL` to skip `--api`.
109
+
110
+ - You must be an **Administrator** of the manifest's `organizationId`.
111
+ - Registering again replaces the saved copy for that `id`, so a changed manifest needs no delete
112
+ first.
113
+ - It prints `Registered <name> (<id>)` when saved.
114
+
115
+ ## Signing in
116
+
117
+ Both commands use `SANITY_AUTH_TOKEN` when it is set, and otherwise the token `npx sanity login`
118
+ saved. Use your own login: the workspace manager refuses robot tokens for `register`, so that
119
+ every project type traces back to a person. If a robot token is exported in your shell (for
120
+ example for a Studio deploy), `unset SANITY_AUTH_TOKEN` before registering.
121
+
122
+ ## Troubleshooting
123
+
124
+ | Message | Fix |
125
+ | ----------------------------------------------------- | ------------------------------------------------------------------ |
126
+ | `No sanity.schema.json` | Run `npx sanity schema extract` in the Studio folder |
127
+ | `workspace-manager.json exists: pass --force` | Add `--force` to replace it, or `--print` to compare |
128
+ | `--starter needs --live` | Add `--live`; starter pages are read from the live project |
129
+ | `Pass --api <url> or set WORKSPACE_MANAGER_API_URL` | Add `--api https://anyday-workspace-manager-sanity-api.vercel.app` |
130
+ | `Use your own Sanity login, not a robot token` | `unset SANITY_AUTH_TOKEN` and use `npx sanity login` |
131
+ | `Register only as an Administrator of organisation …` | Ask an Administrator of that organisation to run `register` |
132
+
133
+ ## How it fits together
134
+
135
+ The full design, including every manifest field, is in
136
+ [docs/project-config.md](https://github.com/anydayagency/anyday-workspace-manager-sanity/blob/main/docs/project-config.md),
137
+ and an overview in
138
+ [docs/how-it-works.md](https://github.com/anydayagency/anyday-workspace-manager-sanity/blob/main/docs/how-it-works.md)
139
+ (Anyday's GitHub organisation only).
140
+
141
+ ## Developing and publishing
142
+
143
+ From a clone of the workspace manager repo, run the CLI without npm:
144
+ `npm run cli -- init ../my-site`. `npm run build` bundles `src/cli.ts`, with the manifest code it
145
+ borrows from the API, into `dist/cli.mjs`; only `@sanity/client` and `ajv` stay dependencies.
146
+
147
+ Publish from `main`: bump `version`, then run
148
+ `npm publish --workspace=@anyday/sanity-workspace-manager --access public --auth-type=web` from the
149
+ repo root. It builds and tests first; the web sign-in covers npm's two-factor check, and the
150
+ release then waits for approval on npmjs.com.
package/dist/cli.mjs ADDED
@@ -0,0 +1,1006 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { existsSync as existsSync3, writeFileSync } from "node:fs";
5
+ import { resolve } from "node:path";
6
+ import { createInterface } from "node:readline/promises";
7
+ import { parseArgs } from "node:util";
8
+
9
+ // ../api/src/projects/manifest.ts
10
+ import { Ajv } from "ajv";
11
+
12
+ // ../api/src/content/color.ts
13
+ function colorValue(hex) {
14
+ const r = parseInt(hex.slice(1, 3), 16);
15
+ const g = parseInt(hex.slice(3, 5), 16);
16
+ const b = parseInt(hex.slice(5, 7), 16);
17
+ const rn = r / 255;
18
+ const gn = g / 255;
19
+ const bn = b / 255;
20
+ const max = Math.max(rn, gn, bn);
21
+ const min = Math.min(rn, gn, bn);
22
+ const d = max - min;
23
+ const l = (max + min) / 2;
24
+ let h = 0;
25
+ if (d !== 0) {
26
+ if (max === rn) h = (gn - bn) / d % 6;
27
+ else if (max === gn) h = (bn - rn) / d + 2;
28
+ else h = (rn - gn) / d + 4;
29
+ h *= 60;
30
+ if (h < 0) h += 360;
31
+ }
32
+ return {
33
+ _type: "color",
34
+ hex,
35
+ alpha: 1,
36
+ hsl: { _type: "hslaColor", h, s: d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1)), l, a: 1 },
37
+ hsv: { _type: "hsvaColor", h, s: max === 0 ? 0 : d / max, v: max, a: 1 },
38
+ rgb: { _type: "rgbaColor", r, g, b, a: 1 }
39
+ };
40
+ }
41
+
42
+ // ../api/src/content/template.ts
43
+ var WHOLE = /^\{\{(\w+)\}\}$/;
44
+ var PLACEHOLDER = /\{\{(\w+)\}\}/g;
45
+ var MissingValue = class extends Error {
46
+ };
47
+ function valueOf(name, values) {
48
+ const value = values[name];
49
+ if (value === void 0) throw new MissingValue(`No value for {{${name}}}`);
50
+ return value;
51
+ }
52
+ function fill(template, values) {
53
+ if (typeof template === "string") {
54
+ const whole = template.match(WHOLE);
55
+ if (whole) return valueOf(whole[1], values);
56
+ return template.replace(PLACEHOLDER, (_, name) => String(valueOf(name, values)));
57
+ }
58
+ if (Array.isArray(template)) return template.map((item) => fill(item, values));
59
+ if (template && typeof template === "object") {
60
+ if ("$color" in template) return colorValue(String(fill(template.$color, values)).toLowerCase());
61
+ return Object.fromEntries(
62
+ Object.entries(template).map(([key, value]) => [key, fill(value, values)])
63
+ );
64
+ }
65
+ return template;
66
+ }
67
+
68
+ // ../api/src/content/brand.ts
69
+ var HEX = /^#[0-9a-f]{6}$/i;
70
+ function brandDocuments(spec, brand, homePageId) {
71
+ const values = {
72
+ siteName: brand.siteName,
73
+ language: brand.language,
74
+ homePageId,
75
+ domain: brand.domain || void 0,
76
+ logoAssetId: brand.logoAssetId || void 0,
77
+ faviconAssetId: brand.faviconAssetId || void 0
78
+ };
79
+ const { documentId: settingsId, fields } = spec.settings;
80
+ const settings = { _id: settingsId, _type: settingsId };
81
+ for (const [field, template] of Object.entries(fields)) {
82
+ try {
83
+ settings[field] = fill(template, values);
84
+ } catch (error) {
85
+ if (!(error instanceof MissingValue)) throw error;
86
+ }
87
+ }
88
+ const { documentId: themeId, colorDefault, color, font, fontFace, defaults } = spec.theme;
89
+ const theme = {
90
+ _id: themeId,
91
+ _type: themeId,
92
+ ...Object.fromEntries(
93
+ spec.colorRoles.map((role) => [
94
+ role.role,
95
+ fill(colorDefault, { hex: role.default, label: role.label })
96
+ ])
97
+ ),
98
+ ...fill(defaults, {})
99
+ };
100
+ for (const [role, hex] of Object.entries(brand.colors ?? {})) {
101
+ if (!HEX.test(hex)) throw new Error(`${role} must be a #rrggbb colour, got "${hex}"`);
102
+ theme[role] = fill(color, { hex });
103
+ }
104
+ for (const [role, faces] of Object.entries(brand.fonts ?? {})) {
105
+ if (!font || !fontFace) throw new Error("This project type takes no fonts");
106
+ theme[role] = fill(font, {
107
+ faces: faces.map((face, index) => fill(fontFace, { ...face, role, index }))
108
+ });
109
+ }
110
+ return { settings, theme };
111
+ }
112
+
113
+ // ../api/src/provision/language-names.ts
114
+ var tagOf = (code) => {
115
+ try {
116
+ return Intl.getCanonicalLocales(code)[0] ?? code;
117
+ } catch {
118
+ return code;
119
+ }
120
+ };
121
+ function englishNameOf(code) {
122
+ return new Intl.DisplayNames(["en"], { type: "language" }).of(tagOf(code)) ?? code;
123
+ }
124
+ function nativeNameOf(code) {
125
+ const tag = tagOf(code);
126
+ try {
127
+ return new Intl.DisplayNames([tag], { type: "language" }).of(tag) ?? englishNameOf(code);
128
+ } catch {
129
+ return englishNameOf(code);
130
+ }
131
+ }
132
+
133
+ // ../api/src/projects/workspace-manager.schema.json
134
+ var workspace_manager_schema_default = {
135
+ $schema: "http://json-schema.org/draft-07/schema#",
136
+ $id: "https://github.com/anydayagency/anyday-workspace-manager-sanity/workspace-manager.schema.json",
137
+ title: "Workspace manager project type",
138
+ description: "How the workspace manager sets up sites for one project type. Templates are JSON with {{name}} placeholders; see docs/project-config.md.",
139
+ type: "object",
140
+ required: [
141
+ "id",
142
+ "title",
143
+ "organizationId",
144
+ "site",
145
+ "siteEnv",
146
+ "schemaSourceProjectId",
147
+ "content",
148
+ "brand"
149
+ ],
150
+ additionalProperties: false,
151
+ definitions: {
152
+ name: {
153
+ type: "string",
154
+ minLength: 1
155
+ },
156
+ document: {
157
+ type: "object",
158
+ required: ["_id", "_type"],
159
+ properties: {
160
+ _id: {
161
+ type: "string"
162
+ },
163
+ _type: {
164
+ type: "string"
165
+ }
166
+ }
167
+ },
168
+ hex: {
169
+ type: "string",
170
+ pattern: "^#[0-9a-fA-F]{6}$"
171
+ }
172
+ },
173
+ properties: {
174
+ $schema: {
175
+ type: "string"
176
+ },
177
+ id: {
178
+ description: "Stable id stored on jobs and used as the Vercel project prefix.",
179
+ type: "string",
180
+ pattern: "^[a-z0-9][a-z0-9-]{0,31}$"
181
+ },
182
+ title: {
183
+ $ref: "#/definitions/name"
184
+ },
185
+ organizationId: {
186
+ description: "The Sanity organisation new workspaces are created in.",
187
+ $ref: "#/definitions/name"
188
+ },
189
+ vercelTeamId: {
190
+ description: "The Vercel team new sites are created in; leave out for the API's default team.",
191
+ $ref: "#/definitions/name"
192
+ },
193
+ site: {
194
+ type: "object",
195
+ required: ["repo", "branch", "revalidatePath"],
196
+ additionalProperties: false,
197
+ properties: {
198
+ repo: {
199
+ description: "owner/name on GitHub",
200
+ type: "string",
201
+ pattern: "^[\\w.-]+/[\\w.-]+$"
202
+ },
203
+ branch: {
204
+ $ref: "#/definitions/name"
205
+ },
206
+ revalidatePath: {
207
+ type: "string",
208
+ pattern: "^/"
209
+ }
210
+ }
211
+ },
212
+ siteEnv: {
213
+ description: "Names of the env vars the site reads.",
214
+ type: "object",
215
+ required: ["projectId", "dataset", "defaultLocale", "studioWorkspace", "baseUrl"],
216
+ additionalProperties: false,
217
+ properties: {
218
+ projectId: {
219
+ $ref: "#/definitions/name"
220
+ },
221
+ dataset: {
222
+ $ref: "#/definitions/name"
223
+ },
224
+ defaultLocale: {
225
+ $ref: "#/definitions/name"
226
+ },
227
+ studioWorkspace: {
228
+ $ref: "#/definitions/name"
229
+ },
230
+ baseUrl: {
231
+ $ref: "#/definitions/name"
232
+ }
233
+ }
234
+ },
235
+ schemaSourceProjectId: {
236
+ description: "The Sanity project whose latest deployed Studio schema each workspace gets.",
237
+ $ref: "#/definitions/name"
238
+ },
239
+ languages: {
240
+ type: "object",
241
+ required: ["document", "setupTypes"],
242
+ additionalProperties: false,
243
+ properties: {
244
+ document: {
245
+ description: "One language's document. Values: code, codeUpper, englishName, nativeName.",
246
+ $ref: "#/definitions/document"
247
+ },
248
+ setupTypes: {
249
+ type: "array",
250
+ items: {
251
+ $ref: "#/definitions/name"
252
+ }
253
+ }
254
+ }
255
+ },
256
+ content: {
257
+ type: "object",
258
+ required: ["sections", "singletons", "homePage", "starter"],
259
+ additionalProperties: false,
260
+ properties: {
261
+ sections: {
262
+ description: "[type, title] pairs the copy picker lists, in order.",
263
+ type: "array",
264
+ items: {
265
+ type: "array",
266
+ items: [
267
+ {
268
+ $ref: "#/definitions/name"
269
+ },
270
+ {
271
+ $ref: "#/definitions/name"
272
+ }
273
+ ],
274
+ minItems: 2,
275
+ maxItems: 2
276
+ }
277
+ },
278
+ singletons: {
279
+ type: "array",
280
+ items: {
281
+ $ref: "#/definitions/name"
282
+ }
283
+ },
284
+ homePage: {
285
+ type: "object",
286
+ required: ["documentId", "field"],
287
+ additionalProperties: false,
288
+ properties: {
289
+ documentId: {
290
+ $ref: "#/definitions/name"
291
+ },
292
+ field: {
293
+ $ref: "#/definitions/name"
294
+ }
295
+ }
296
+ },
297
+ starter: {
298
+ description: "Values: siteName, language.",
299
+ type: "object",
300
+ required: ["homeId", "documents"],
301
+ additionalProperties: false,
302
+ properties: {
303
+ homeId: {
304
+ $ref: "#/definitions/name"
305
+ },
306
+ documents: {
307
+ type: "array",
308
+ items: {
309
+ $ref: "#/definitions/document"
310
+ }
311
+ }
312
+ }
313
+ }
314
+ }
315
+ },
316
+ brand: {
317
+ type: "object",
318
+ required: ["colorRoles", "settings", "theme"],
319
+ additionalProperties: false,
320
+ properties: {
321
+ colorRoles: {
322
+ type: "array",
323
+ items: {
324
+ type: "object",
325
+ required: ["role", "title", "description", "default", "label"],
326
+ additionalProperties: false,
327
+ properties: {
328
+ role: {
329
+ $ref: "#/definitions/name"
330
+ },
331
+ title: {
332
+ $ref: "#/definitions/name"
333
+ },
334
+ description: {
335
+ type: "string"
336
+ },
337
+ default: {
338
+ $ref: "#/definitions/hex"
339
+ },
340
+ label: {
341
+ type: "string"
342
+ }
343
+ }
344
+ }
345
+ },
346
+ settings: {
347
+ type: "object",
348
+ required: ["documentId", "fields"],
349
+ additionalProperties: false,
350
+ properties: {
351
+ documentId: {
352
+ $ref: "#/definitions/name"
353
+ },
354
+ fields: {
355
+ description: "Values: siteName, language, homePageId, domain, logoAssetId, faviconAssetId. A field whose value is missing is left out.",
356
+ type: "object"
357
+ }
358
+ }
359
+ },
360
+ theme: {
361
+ type: "object",
362
+ required: ["documentId", "colorDefault", "color", "defaults"],
363
+ additionalProperties: false,
364
+ properties: {
365
+ documentId: {
366
+ $ref: "#/definitions/name"
367
+ },
368
+ colorDefault: {
369
+ description: "Values: hex, label."
370
+ },
371
+ color: {
372
+ description: "Values: hex."
373
+ },
374
+ font: {
375
+ description: "Values: faces. Leave out, with fontFace, if the project takes no fonts."
376
+ },
377
+ fontFace: {
378
+ description: "Values: role, index, family, weight, style, fileAssetId."
379
+ },
380
+ defaults: {
381
+ type: "object"
382
+ }
383
+ }
384
+ }
385
+ }
386
+ }
387
+ }
388
+ };
389
+
390
+ // ../api/src/projects/manifest.ts
391
+ var validate = new Ajv({ allErrors: true }).compile(workspace_manager_schema_default);
392
+ var InvalidManifest = class extends Error {
393
+ };
394
+ function parseManifest(json) {
395
+ if (validate(json)) return json;
396
+ const problems = validate.errors.map((error) => `${error.instancePath || "/"} ${error.message}`);
397
+ throw new InvalidManifest(`Invalid workspace-manager.json:
398
+ ${problems.join("\n")}`);
399
+ }
400
+ function configFrom(manifest) {
401
+ const { languages, content, brand } = manifest;
402
+ const codeField = languages && Object.keys(languages.document).find((field) => languages.document[field] === "{{code}}");
403
+ if (languages && !codeField) {
404
+ throw new InvalidManifest('languages.document needs a field set to "{{code}}"');
405
+ }
406
+ return {
407
+ id: manifest.id,
408
+ title: manifest.title,
409
+ organizationId: manifest.organizationId,
410
+ ...manifest.vercelTeamId ? { vercelTeamId: manifest.vercelTeamId } : {},
411
+ site: manifest.site,
412
+ siteEnv: manifest.siteEnv,
413
+ schemaSourceProjectId: manifest.schemaSourceProjectId,
414
+ languageDocument: languages && ((code) => fill(languages.document, {
415
+ code,
416
+ codeUpper: code.toUpperCase(),
417
+ englishName: englishNameOf(code),
418
+ nativeName: nativeNameOf(code)
419
+ })),
420
+ content: {
421
+ sections: content.sections,
422
+ singletons: content.singletons,
423
+ homePage: content.homePage,
424
+ languages: languages && {
425
+ type: languages.document._type,
426
+ codeField,
427
+ setupTypes: languages.setupTypes
428
+ },
429
+ starter: (values) => fill(content.starter.documents, values),
430
+ starterHomeId: content.starter.homeId
431
+ },
432
+ brand: {
433
+ colorRoles: brand.colorRoles,
434
+ documents: (values, homePageId) => brandDocuments(brand, values, homePageId)
435
+ }
436
+ };
437
+ }
438
+
439
+ // ../api/src/content/labels.ts
440
+ function humanize(name) {
441
+ const words = name.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([a-z])([0-9])/g, "$1 $2").toLowerCase();
442
+ return words.charAt(0).toUpperCase() + words.slice(1);
443
+ }
444
+
445
+ // src/init/extract.ts
446
+ var PLUGIN_TYPES = /^(sanity\.|system\.|assist\.|media\.tag$|translation\.metadata$|fieldTranslation\.metadata$)/;
447
+ var Extract = class {
448
+ byName;
449
+ constructor(types) {
450
+ this.byName = new Map(types.map((type) => [type.name, type]));
451
+ }
452
+ get documents() {
453
+ return [...this.byName.values()].filter((type) => type.type === "document");
454
+ }
455
+ /** Document types an editor makes, not plugins' bookkeeping. */
456
+ get contentDocuments() {
457
+ return this.documents.filter((type) => !PLUGIN_TYPES.test(type.name));
458
+ }
459
+ has(name) {
460
+ return this.byName.has(name);
461
+ }
462
+ fields(typeName) {
463
+ const type = this.byName.get(typeName);
464
+ return type?.attributes ?? (type?.value?.type === "object" ? type.value.attributes : {}) ?? {};
465
+ }
466
+ /** The named type an attribute uses, e.g. "themeColor", or null for an inline shape. */
467
+ inlineName(value) {
468
+ return value.type === "inline" ? value.name ?? null : null;
469
+ }
470
+ referenceTarget(value) {
471
+ return this.inlineName(value)?.match(/^(.+)\.reference$/)?.[1] ?? null;
472
+ }
473
+ /** Where a colour sits: the attribute itself, or a field of the object type it uses. */
474
+ colorShape(value) {
475
+ const name = this.inlineName(value);
476
+ if (name === "color") return "direct";
477
+ if (!name) return null;
478
+ const fields = this.fields(name);
479
+ const field = Object.keys(fields).find((key) => this.inlineName(fields[key].value) === "color");
480
+ return field ? { wrapper: name, field, label: "label" in fields } : null;
481
+ }
482
+ /** An object type holding an array of faces, each with an uploaded file. */
483
+ fontShape(value) {
484
+ const family = this.inlineName(value);
485
+ if (!family) return null;
486
+ for (const [list, { value: listValue }] of Object.entries(this.fields(family))) {
487
+ const item = listValue.type === "array" ? listValue.of : void 0;
488
+ if (!item) continue;
489
+ const fields = this.attributesOf(item);
490
+ const face = fields._type?.value.value ?? this.inlineName(item);
491
+ const file = Object.keys(fields).find(
492
+ (key) => this.holdsAsset(fields[key].value, "sanity.fileAsset.reference")
493
+ );
494
+ if (face && file) return { family, list, face, file, faceFields: Object.keys(fields) };
495
+ }
496
+ return null;
497
+ }
498
+ isImage(value) {
499
+ return this.holdsAsset(value, "sanity.imageAsset.reference");
500
+ }
501
+ attributesOf(value) {
502
+ const name = this.inlineName(value);
503
+ return name ? this.fields(name) : value.attributes ?? {};
504
+ }
505
+ holdsAsset(value, reference) {
506
+ return Object.values(this.attributesOf(value)).some(
507
+ (inner) => this.inlineName(inner.value) === reference
508
+ );
509
+ }
510
+ };
511
+
512
+ // src/init/draft.ts
513
+ var SYSTEM_FIELDS = /* @__PURE__ */ new Set(["_rev", "_createdAt", "_updatedAt", "_system"]);
514
+ var ENV_DEFAULTS = {
515
+ projectId: "NEXT_PUBLIC_SANITY_PROJECT_ID",
516
+ dataset: "NEXT_PUBLIC_SANITY_DATASET",
517
+ defaultLocale: "NEXT_PUBLIC_DEFAULT_LOCALE",
518
+ studioWorkspace: "NEXT_PUBLIC_STUDIO_WORKSPACE",
519
+ baseUrl: "NEXT_PUBLIC_BASE_URL"
520
+ };
521
+ var ENV_PATTERNS = {
522
+ projectId: /SANITY_PROJECT_ID$/,
523
+ dataset: /SANITY_DATASET$/,
524
+ defaultLocale: /DEFAULT_LOCALE$/,
525
+ studioWorkspace: /STUDIO_WORKSPACE$/,
526
+ baseUrl: /(BASE|SITE)_URL$/
527
+ };
528
+ function suggestNames(repo) {
529
+ const name = (repo?.split("/")[1] ?? "site").replace(/\.git$/, "");
530
+ const id = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32);
531
+ return { id: id || "site", title: humanize(name.replace(/[-_]+/g, " ").trim()) || "Site" };
532
+ }
533
+ function draftManifest(repo, answers, live = {}) {
534
+ const notes = [];
535
+ const note = (message) => void notes.push(message);
536
+ const schema = new Extract(repo.types);
537
+ const singletons = schema.contentDocuments.map((type) => type.name).filter((name) => /settings$/i.test(name));
538
+ if (singletons.length)
539
+ note(`Singletons guessed from names ending in "Settings": ${singletons.join(", ")}`);
540
+ else note("No singletons found: list your settings documents in content.singletons");
541
+ const homePage = findHomePage(schema, singletons);
542
+ if (homePage) note(`Home page: ${homePage.documentId}.${homePage.field}`);
543
+ else note("No home page reference found: set content.homePage by hand");
544
+ const home = homePage ?? { documentId: singletons[0] ?? "siteSettings", field: "homePage" };
545
+ const languages = draftLanguages(schema, note);
546
+ const sections = schema.contentDocuments.map((type) => type.name).filter((name) => !singletons.includes(name) && !languages?.setupTypes.includes(name)).map((name) => [name, humanize(name)]);
547
+ note(`Copy sections list every content type (${sections.length}): remove the ones not to offer`);
548
+ const siteEnv = Object.fromEntries(
549
+ Object.entries(ENV_PATTERNS).map(([key, pattern]) => {
550
+ const matches = repo.envNames.filter((name) => pattern.test(name));
551
+ const found = matches.find((name) => name.startsWith("NEXT_PUBLIC_")) ?? matches[0];
552
+ if (!found)
553
+ note(
554
+ `No env var for ${key} in .env.example: using ${ENV_DEFAULTS[key]}`
555
+ );
556
+ return [key, found ?? ENV_DEFAULTS[key]];
557
+ })
558
+ );
559
+ if (!repo.repo) note("No GitHub remote found: set site.repo to owner/name");
560
+ if (!repo.hasRevalidateRoute) note("No /api/revalidate route found: check site.revalidatePath");
561
+ return {
562
+ manifest: {
563
+ id: answers.id,
564
+ title: answers.title,
565
+ organizationId: answers.organizationId,
566
+ site: {
567
+ repo: repo.repo ?? "owner/name",
568
+ branch: repo.branch ?? "main",
569
+ revalidatePath: "/api/revalidate"
570
+ },
571
+ siteEnv,
572
+ schemaSourceProjectId: answers.schemaSourceProjectId,
573
+ ...languages ? { languages } : {},
574
+ content: {
575
+ sections,
576
+ singletons,
577
+ homePage: home,
578
+ starter: draftStarter(schema, home, live, note)
579
+ },
580
+ brand: draftBrand(schema, home, live, note)
581
+ },
582
+ notes
583
+ };
584
+ }
585
+ function findHomePage(schema, singletons) {
586
+ for (const documentId of singletons) {
587
+ const fields = schema.fields(documentId);
588
+ const field = Object.keys(fields).find(
589
+ (key) => /home/i.test(key) && schema.referenceTarget(fields[key].value)
590
+ );
591
+ if (field) return { documentId, field };
592
+ }
593
+ return null;
594
+ }
595
+ var LANGUAGE_FIELDS = {
596
+ code: "{{code}}",
597
+ title: "{{englishName}}",
598
+ name: "{{englishName}}",
599
+ nativeName: "{{nativeName}}",
600
+ shortName: "{{codeUpper}}"
601
+ };
602
+ function draftLanguages(schema, note) {
603
+ const type = schema.contentDocuments.concat(schema.documents).find(
604
+ (entry) => /(^|\.)(locale|language)$/.test(entry.name) && "code" in schema.fields(entry.name)
605
+ );
606
+ if (!type) {
607
+ note("No language document type found: languages left out");
608
+ return void 0;
609
+ }
610
+ const fields = schema.fields(type.name);
611
+ const document = {
612
+ _id: `${type.name.split(".").pop()}-{{code}}`,
613
+ _type: type.name
614
+ };
615
+ for (const [field, value] of Object.entries(LANGUAGE_FIELDS))
616
+ if (field in fields) document[field] = value;
617
+ if ("enabled" in fields) document.enabled = true;
618
+ const prefix = type.name.includes(".") ? `${type.name.split(".")[0]}.` : null;
619
+ const setupTypes = prefix ? schema.documents.map((entry) => entry.name).filter((name) => name.startsWith(prefix)) : [type.name];
620
+ note(`Language documents: ${type.name}, ids like ${document._id}; check the id pattern`);
621
+ return { document, setupTypes };
622
+ }
623
+ function draftStarter(schema, home, live, note) {
624
+ if (live.starter?.length) {
625
+ const siteName = siteNameOf(live.settings);
626
+ const language = live.starter.find((doc) => typeof doc.language === "string")?.language;
627
+ const ids = new Set(live.starter.map((doc) => doc._id));
628
+ const documents = live.starter.map((doc) => placeholders(stripSystem(doc), siteName, language));
629
+ const outside = live.starter.flatMap((doc) => references(doc).filter((ref) => !ids.has(ref)));
630
+ if (outside.length)
631
+ note(
632
+ `Starter pages link to documents not in the starter: ${[...new Set(outside)].join(", ")}`
633
+ );
634
+ const liveHome = live.settings?.[home.field]?._ref;
635
+ const homeId = liveHome && ids.has(liveHome) ? liveHome : live.starter[0]._id;
636
+ note(`Starter: ${documents.length} documents from the live project, home ${homeId}`);
637
+ return { homeId, documents };
638
+ }
639
+ const homeField = schema.fields(home.documentId)[home.field];
640
+ const type = (homeField && schema.referenceTarget(homeField.value)) ?? "page";
641
+ const fields = schema.fields(type);
642
+ const page = { _id: "starter-home", _type: type };
643
+ if ("title" in fields) page.title = "{{siteName}}";
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");
646
+ return { homeId: "starter-home", documents: [page] };
647
+ }
648
+ function draftBrand(schema, home, live, note) {
649
+ const settingsFields = schema.fields(home.documentId);
650
+ const fields = {};
651
+ const siteName = settingsFields.siteName;
652
+ if (siteName) {
653
+ fields.siteName = schema.inlineName(siteName.value) === "internationalizedArrayString" ? [
654
+ {
655
+ _key: "{{language}}",
656
+ _type: "internationalizedArrayStringValue",
657
+ language: "{{language}}",
658
+ value: "{{siteName}}"
659
+ }
660
+ ] : "{{siteName}}";
661
+ }
662
+ fields[home.field] = { _type: "reference", _ref: "{{homePageId}}" };
663
+ if (settingsFields.domain) fields.domain = "{{domain}}";
664
+ for (const [field, id] of [
665
+ ["logo", "logoAssetId"],
666
+ ["favicon", "faviconAssetId"]
667
+ ]) {
668
+ const value = settingsFields[field]?.value;
669
+ if (!value || !schema.isImage(value)) continue;
670
+ fields[field] = {
671
+ _type: "image",
672
+ asset: { _type: "reference", _ref: `{{${id}}}` },
673
+ ...field === "logo" && "alt" in (value.attributes ?? {}) ? { alt: "{{siteName}}" } : {}
674
+ };
675
+ }
676
+ const themeType = schema.contentDocuments.find((type) => /theme/i.test(type.name));
677
+ const themeId = themeType?.name ?? "themeSettings";
678
+ if (!themeType) note("No theme document found: brand colours and fonts left empty");
679
+ const themeFields = schema.fields(themeId);
680
+ let color = { $color: "{{hex}}" };
681
+ let colorDefault = color;
682
+ const colorRoles = [];
683
+ for (const [role, { value }] of Object.entries(themeFields)) {
684
+ const shape = schema.colorShape(value);
685
+ if (!shape) continue;
686
+ if (shape !== "direct") {
687
+ const wrapped = { _type: shape.wrapper, [shape.field]: { $color: "{{hex}}" } };
688
+ color = wrapped;
689
+ colorDefault = shape.label ? { ...wrapped, label: "{{label}}" } : wrapped;
690
+ }
691
+ const current = live.theme?.[role];
692
+ const hex = shape === "direct" ? current?.hex : current?.[shape.field]?.hex;
693
+ colorRoles.push({
694
+ role,
695
+ title: humanize(role),
696
+ description: "",
697
+ default: hex ?? "#000000",
698
+ label: typeof current?.label === "string" ? current.label : humanize(role)
699
+ });
700
+ }
701
+ if (colorRoles.length && !live.theme)
702
+ note("Colour defaults are #000000: run with --live to read them");
703
+ const fontShape = Object.values(themeFields).map(({ value }) => schema.fontShape(value)).find(Boolean);
704
+ const fonts = fontShape ? {
705
+ font: { _type: fontShape.family, [fontShape.list]: "{{faces}}" },
706
+ fontFace: {
707
+ _key: "{{role}}-{{index}}",
708
+ _type: fontShape.face,
709
+ ...Object.fromEntries(
710
+ ["family", "weight", "style"].filter((field) => fontShape.faceFields.includes(field)).map((field) => [field, `{{${field}}}`])
711
+ ),
712
+ [fontShape.file]: { _type: "file", asset: { _type: "reference", _ref: "{{fileAssetId}}" } }
713
+ }
714
+ } : {};
715
+ if (!fontShape) note("No font fields found on the theme: fonts left out");
716
+ const defaults = live.theme ? compactColors(stripSystem(live.theme)) : {};
717
+ delete defaults._id;
718
+ delete defaults._type;
719
+ if (!live.theme) note("Theme defaults are empty: run with --live to copy the current theme");
720
+ const linked = Object.keys(defaults).filter((field) => hasReference(defaults[field]));
721
+ for (const field of linked) delete defaults[field];
722
+ if (linked.length) {
723
+ note(
724
+ `Left out of theme defaults, as they point at the source project's files: ${linked.join(", ")}`
725
+ );
726
+ }
727
+ return {
728
+ colorRoles,
729
+ settings: { documentId: home.documentId, fields },
730
+ theme: { documentId: themeId, colorDefault, color, ...fonts, defaults }
731
+ };
732
+ }
733
+ function siteNameOf(settings) {
734
+ const value = settings?.siteName;
735
+ if (typeof value === "string") return value;
736
+ if (Array.isArray(value)) return value[0]?.value;
737
+ return void 0;
738
+ }
739
+ function stripSystem(doc) {
740
+ return Object.fromEntries(Object.entries(doc).filter(([key]) => !SYSTEM_FIELDS.has(key)));
741
+ }
742
+ var I18N_VALUE = /^internationalizedArray\w*Value$/;
743
+ function placeholders(value, siteName, language, key, parent) {
744
+ if (typeof value === "string") {
745
+ const isLanguage = language !== void 0 && value === language && (key === "language" || key === "_key" && I18N_VALUE.test(parent?._type ?? ""));
746
+ if (isLanguage) return "{{language}}";
747
+ return siteName && siteName.length > 2 ? value.split(siteName).join("{{siteName}}") : value;
748
+ }
749
+ if (Array.isArray(value)) return value.map((item) => placeholders(item, siteName, language));
750
+ if (value && typeof value === "object") {
751
+ return Object.fromEntries(
752
+ Object.entries(value).map(([k, v]) => [k, placeholders(v, siteName, language, k, value)])
753
+ );
754
+ }
755
+ return value;
756
+ }
757
+ var hasReference = (value) => JSON.stringify(value ?? null).includes('"_ref":');
758
+ function references(value) {
759
+ if (Array.isArray(value)) return value.flatMap(references);
760
+ if (value && typeof value === "object") {
761
+ const ref = value._ref;
762
+ const own = typeof ref === "string" && !/^(image|file)-/.test(ref) ? [ref] : [];
763
+ return [...own, ...Object.values(value).flatMap(references)];
764
+ }
765
+ return [];
766
+ }
767
+ function compactColors(value) {
768
+ if (Array.isArray(value)) return value.map(compactColors);
769
+ if (value && typeof value === "object") {
770
+ const object = value;
771
+ if (object._type === "color" && typeof object.hex === "string") return { $color: object.hex };
772
+ return Object.fromEntries(Object.entries(object).map(([k, v]) => [k, compactColors(v)]));
773
+ }
774
+ return value;
775
+ }
776
+
777
+ // src/init/live.ts
778
+ import { existsSync, readFileSync } from "node:fs";
779
+ import { homedir } from "node:os";
780
+ import { join } from "node:path";
781
+ import { createClient } from "@sanity/client";
782
+ function sanityToken() {
783
+ if (process.env.SANITY_AUTH_TOKEN) return process.env.SANITY_AUTH_TOKEN;
784
+ const config = join(homedir(), ".config", "sanity", "config.json");
785
+ if (!existsSync(config)) return null;
786
+ return JSON.parse(readFileSync(config, "utf8")).authToken ?? null;
787
+ }
788
+ async function organizationOf(projectId, token) {
789
+ const response = await fetch(`https://api.sanity.io/v2021-06-07/projects/${projectId}`, {
790
+ headers: { Authorization: `Bearer ${token}` }
791
+ });
792
+ if (!response.ok) throw new Error(`Couldn't read project ${projectId}: ${response.status}`);
793
+ return (await response.json()).organizationId ?? "";
794
+ }
795
+ 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
+ });
804
+ const docs = await client.fetch("*[_id in $ids]", {
805
+ ids: [options.settingsId, options.themeId, ...options.starterIds]
806
+ });
807
+ const byId = new Map(docs.map((doc) => [doc._id, doc]));
808
+ const missing = options.starterIds.filter((id) => !byId.has(id));
809
+ if (missing.length) throw new Error(`No published documents with ids: ${missing.join(", ")}`);
810
+ return {
811
+ settings: byId.get(options.settingsId) ?? null,
812
+ theme: byId.get(options.themeId) ?? null,
813
+ starter: options.starterIds.map((id) => byId.get(id))
814
+ };
815
+ }
816
+
817
+ // src/init/repo.ts
818
+ import { execFileSync } from "node:child_process";
819
+ import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync2 } from "node:fs";
820
+ import { join as join2 } from "node:path";
821
+ var APP_DIRS = [".", "frontend", "web", "site", "studio", "apps"];
822
+ var git = (dir, ...args) => {
823
+ try {
824
+ return execFileSync("git", ["-C", dir, ...args], { encoding: "utf8", stdio: "pipe" }).trim();
825
+ } catch {
826
+ return null;
827
+ }
828
+ };
829
+ function repoFromRemote(remote) {
830
+ return remote?.match(/[:/]([\w.-]+\/[\w.-]+?)(?:\.git)?\/?$/)?.[1] ?? null;
831
+ }
832
+ function appDirs(root) {
833
+ const apps = join2(root, "apps");
834
+ const nested = existsSync2(apps) ? readdirSync(apps, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => join2(apps, entry.name)) : [];
835
+ return [...APP_DIRS.map((dir) => join2(root, dir)), ...nested].filter((dir) => existsSync2(dir));
836
+ }
837
+ function readRepo(root) {
838
+ const dirs = appDirs(root);
839
+ const schemaPath = dirs.map((dir) => join2(dir, "sanity.schema.json")).find(existsSync2);
840
+ if (!schemaPath) {
841
+ throw new Error("No sanity.schema.json: run `npx sanity schema extract` in the Studio first");
842
+ }
843
+ const envNames = dirs.flatMap((dir) => {
844
+ const file = join2(dir, ".env.example");
845
+ if (!existsSync2(file)) return [];
846
+ return [...readFileSync2(file, "utf8").matchAll(/^\s*([A-Z][A-Z0-9_]*)\s*=/gm)].map((m) => m[1]);
847
+ });
848
+ const projectId = dirs.flatMap((dir) => ["sanity.cli.ts", "sanity.config.ts"].map((file) => join2(dir, file))).filter(existsSync2).map((file) => readFileSync2(file, "utf8").match(/projectId:\s*['"]([a-z0-9]+)['"]/)?.[1]).find(Boolean) ?? null;
849
+ const hasRevalidateRoute = dirs.some(
850
+ (dir) => ["app", "src/app", "pages", "src/pages"].some(
851
+ (base) => existsSync2(join2(dir, base, "api/revalidate")) || ["ts", "js"].some((ext) => existsSync2(join2(dir, base, `api/revalidate.${ext}`)))
852
+ )
853
+ );
854
+ return {
855
+ repo: repoFromRemote(git(root, "remote", "get-url", "origin")),
856
+ branch: git(root, "symbolic-ref", "--short", "refs/remotes/origin/HEAD")?.replace(/^origin\//, "") ?? null,
857
+ envNames: [...new Set(envNames)],
858
+ projectId,
859
+ hasRevalidateRoute,
860
+ types: JSON.parse(readFileSync2(schemaPath, "utf8"))
861
+ };
862
+ }
863
+
864
+ // src/register.ts
865
+ import { readFileSync as readFileSync3 } from "node:fs";
866
+ import { join as join3 } from "node:path";
867
+ async function register(options) {
868
+ const file = join3(options.dir, "workspace-manager.json");
869
+ const manifest = parseManifest(JSON.parse(readFileSync3(file, "utf8")));
870
+ configFrom(manifest);
871
+ const response = await (options.fetch ?? fetch)(
872
+ `${options.api.replace(/\/+$/, "")}/api/project-types`,
873
+ {
874
+ method: "POST",
875
+ headers: { "Authorization": `Bearer ${options.token}`, "Content-Type": "application/json" },
876
+ body: JSON.stringify(manifest)
877
+ }
878
+ );
879
+ const body = await response.json().catch(() => ({}));
880
+ if (!response.ok)
881
+ throw new Error(`Not registered (${response.status}): ${body.error ?? "no reason given"}`);
882
+ return body;
883
+ }
884
+
885
+ // src/cli.ts
886
+ var USAGE = `Usage: sanity-workspace-manager <init|register> [dir] [options]
887
+
888
+ init [dir]
889
+
890
+ Drafts workspace-manager.json for a site repo from its git remote, .env.example
891
+ and sanity.schema.json (run \`npx sanity schema extract\` first).
892
+
893
+ Options:
894
+ --yes Accept the suggested id, title and project without asking
895
+ --project <id> The Sanity project new workspaces copy their schema from
896
+ --org <id> The Sanity organisation new workspaces are created in (found with --live)
897
+ --live Read the current theme, settings and starter pages from that project
898
+ --dataset <name> Dataset to read with --live (default: production)
899
+ --starter <ids> Comma-separated document ids to use as starter content (needs --live)
900
+ --force Overwrite an existing workspace-manager.json
901
+ --print Print the draft instead of writing it
902
+
903
+ register [dir]
904
+ Checks the repo's workspace-manager.json and saves it through the workspace manager API,
905
+ signed in as you (SANITY_AUTH_TOKEN or \`npx sanity login\`).
906
+
907
+ Options:
908
+ --api <url> The workspace manager API (or set WORKSPACE_MANAGER_API_URL)`;
909
+ var dirOf = (positional) => resolve(process.env.INIT_CWD ?? ".", positional ?? ".");
910
+ async function registerCommand(argv) {
911
+ const { values, positionals } = parseArgs({
912
+ args: argv,
913
+ allowPositionals: true,
914
+ options: { api: { type: "string" } }
915
+ });
916
+ const api = values.api ?? process.env.WORKSPACE_MANAGER_API_URL;
917
+ if (!api) throw new Error("Pass --api <url> or set WORKSPACE_MANAGER_API_URL");
918
+ const token = sanityToken();
919
+ if (!token) throw new Error("register needs SANITY_AUTH_TOKEN or `npx sanity login`");
920
+ const saved = await register({ dir: dirOf(positionals[0]), api, token });
921
+ console.log(`Registered ${saved.title} (${saved.id})`);
922
+ }
923
+ async function init(argv) {
924
+ const { values, positionals } = parseArgs({
925
+ args: argv,
926
+ allowPositionals: true,
927
+ options: {
928
+ yes: { type: "boolean", default: false },
929
+ project: { type: "string" },
930
+ org: { type: "string" },
931
+ live: { type: "boolean", default: false },
932
+ dataset: { type: "string", default: "production" },
933
+ starter: { type: "string" },
934
+ force: { type: "boolean", default: false },
935
+ print: { type: "boolean", default: false }
936
+ }
937
+ });
938
+ const root = dirOf(positionals[0]);
939
+ const out = resolve(root, "workspace-manager.json");
940
+ if (existsSync3(out) && !values.force && !values.print)
941
+ throw new Error(`${out} exists: pass --force to replace it`);
942
+ if (values.starter && !values.live) throw new Error("--starter needs --live");
943
+ const token = values.live ? sanityToken() : null;
944
+ if (values.live && !token) throw new Error("--live needs SANITY_AUTH_TOKEN or `npx sanity login`");
945
+ const orgFor = async (projectId) => values.org ?? (token && projectId ? await organizationOf(projectId, token) : "");
946
+ const repo = readRepo(root);
947
+ const suggested = { ...suggestNames(repo.repo), project: values.project ?? repo.projectId ?? "" };
948
+ const answers = values.yes ? { ...suggested, org: await orgFor(suggested.project) } : await ask(suggested, orgFor);
949
+ if (!answers.project)
950
+ throw new Error("Pass --project <id>: the Sanity project to copy schemas from");
951
+ if (!answers.org) throw new Error("Pass --org <id>, or --live to read it from the project");
952
+ const base = {
953
+ id: answers.id,
954
+ title: answers.title,
955
+ organizationId: answers.org,
956
+ schemaSourceProjectId: answers.project
957
+ };
958
+ let draft = draftManifest(repo, base);
959
+ if (token) {
960
+ const live = await readLive({
961
+ projectId: answers.project,
962
+ dataset: values.dataset,
963
+ token,
964
+ settingsId: draft.manifest.brand.settings.documentId,
965
+ themeId: draft.manifest.brand.theme.documentId,
966
+ starterIds: values.starter?.split(",").map((id) => id.trim()).filter(Boolean) ?? []
967
+ });
968
+ draft = draftManifest(repo, base, live);
969
+ }
970
+ configFrom(parseManifest(draft.manifest));
971
+ const json = `${JSON.stringify(draft.manifest, null, 2)}
972
+ `;
973
+ if (values.print) process.stdout.write(json);
974
+ else writeFileSync(out, json);
975
+ console.error(`${values.print ? "Drafted" : `Wrote ${out}`}
976
+
977
+ Check before registering:`);
978
+ for (const note of draft.notes) console.error(` - ${note}`);
979
+ }
980
+ async function ask(suggested, orgFor) {
981
+ const prompt = createInterface({ input: process.stdin, output: process.stdout });
982
+ const question = async (label, fallback) => (await prompt.question(`${label}${fallback ? ` (${fallback})` : ""}: `)).trim() || fallback;
983
+ try {
984
+ const id = await question("Project type id, short and lowercase", suggested.id);
985
+ const title = await question("Name shown in the app", suggested.title);
986
+ const project = await question("Sanity project to copy schemas from", suggested.project);
987
+ const org = await question("Sanity organisation to create workspaces in", await orgFor(project));
988
+ return { id, title, project, org };
989
+ } finally {
990
+ prompt.close();
991
+ }
992
+ }
993
+ var commands = {
994
+ init,
995
+ register: registerCommand
996
+ };
997
+ var [command, ...rest] = process.argv.slice(2);
998
+ var run = command ? commands[command] : void 0;
999
+ if (!run) {
1000
+ console.log(USAGE);
1001
+ process.exit(command ? 1 : 0);
1002
+ }
1003
+ run(rest).catch((error) => {
1004
+ console.error(error instanceof Error ? error.message : error);
1005
+ process.exit(1);
1006
+ });
package/package.json CHANGED
@@ -1,6 +1,41 @@
1
1
  {
2
2
  "name": "@anyday/sanity-workspace-manager",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Drafts a site repo's workspace-manager.json for the Anyday workspace manager",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/anydayagency/anyday-workspace-manager-sanity.git",
8
+ "directory": "cli"
9
+ },
10
+ "license": "UNLICENSED",
11
+ "type": "module",
12
+ "bin": {
13
+ "sanity-workspace-manager": "./dist/cli.mjs"
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "README.md"
18
+ ],
19
+ "scripts": {
20
+ "build": "esbuild src/cli.ts --bundle --platform=node --format=esm --target=node20 --outfile=dist/cli.mjs --external:@sanity/client --external:ajv --banner:js='#!/usr/bin/env node'",
21
+ "prepublishOnly": "npm run build && npm test",
22
+ "start": "tsx src/cli.ts",
23
+ "test": "vitest run",
24
+ "type-check": "tsc --noEmit"
25
+ },
26
+ "dependencies": {
27
+ "@sanity/client": "^7.27.0",
28
+ "ajv": "^8.20.0"
29
+ },
30
+ "devDependencies": {
31
+ "@anyday/workspace-manager-api": "*",
32
+ "@types/node": "^24.19.1",
33
+ "esbuild": "^0.28.2",
34
+ "tsx": "^4.23.15",
35
+ "typescript": "^5.9.3",
36
+ "vitest": "^4.1.11"
37
+ },
38
+ "engines": {
39
+ "node": ">=20"
40
+ }
41
+ }