@starcier/create-builder 0.0.0-stage → 1.0.0-next.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 (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +56 -2
  3. package/dist/api.js +44 -0
  4. package/dist/args.js +77 -0
  5. package/dist/auth.js +68 -0
  6. package/dist/cli.js +23 -0
  7. package/dist/errors.js +10 -0
  8. package/dist/main.js +164 -0
  9. package/dist/prompt.js +23 -0
  10. package/dist/slug.js +20 -0
  11. package/dist/template.js +61 -0
  12. package/package.json +34 -4
  13. package/template/.claude/skills/design-system/SKILL.md +177 -0
  14. package/template/.claude/skills/design-system-audit/SKILL.md +125 -0
  15. package/template/.claude/skills/design-work-review/SKILL.md +93 -0
  16. package/template/.claude/skills/process-interviewer/SKILL.md +124 -0
  17. package/template/.claude/skills/process-interviewer/references/plan-output-template.md +53 -0
  18. package/template/.claude/skills/process-interviewer/references/skill-output-template.md +72 -0
  19. package/template/.claude/skills/prototype-page-builder/SKILL.md +76 -0
  20. package/template/.cursor/rules/code-review.mdc +30 -0
  21. package/template/.cursor/rules/prototype-authoring.mdc +31 -0
  22. package/template/.cursor/rules/tokens.mdc +36 -0
  23. package/template/.cursor/rules/ui-system.mdc +39 -0
  24. package/template/.env.example +19 -0
  25. package/template/AGENTS.md +77 -0
  26. package/template/CLAUDE.md +19 -0
  27. package/template/README.md +65 -0
  28. package/template/_gitignore +15 -0
  29. package/template/docs/design-system-log.md +17 -0
  30. package/template/eslint.config.js +23 -0
  31. package/template/index.html +13 -0
  32. package/template/package.json +35 -0
  33. package/template/public/favicon.svg +1 -0
  34. package/template/src/App.tsx +65 -0
  35. package/template/src/app.css +5 -0
  36. package/template/src/brand.ts +2 -0
  37. package/template/src/config.ts +15 -0
  38. package/template/src/design-system/ThemeProvider.tsx +25 -0
  39. package/template/src/design-system/components/Button/Button.tsx +15 -0
  40. package/template/src/design-system/components/Button/button.css +62 -0
  41. package/template/src/design-system/components/Button/index.ts +1 -0
  42. package/template/src/design-system/components/Card/Card.tsx +22 -0
  43. package/template/src/design-system/components/Card/card.css +27 -0
  44. package/template/src/design-system/components/Card/index.ts +1 -0
  45. package/template/src/design-system/components/Field/Field.tsx +28 -0
  46. package/template/src/design-system/components/Field/field.css +37 -0
  47. package/template/src/design-system/components/Field/index.ts +1 -0
  48. package/template/src/design-system/components/Modal/Modal.tsx +55 -0
  49. package/template/src/design-system/components/Modal/index.ts +1 -0
  50. package/template/src/design-system/components/Modal/modal.css +39 -0
  51. package/template/src/design-system/components/ThemeToggle/ThemeToggle.tsx +13 -0
  52. package/template/src/design-system/components/ThemeToggle/index.ts +1 -0
  53. package/template/src/design-system/global.css +26 -0
  54. package/template/src/design-system/palette.ts +40 -0
  55. package/template/src/design-system/theme.ts +55 -0
  56. package/template/src/design-system/tokens.ts +126 -0
  57. package/template/src/main.tsx +13 -0
  58. package/template/src/prototypes/getting-started/welcome/README.md +6 -0
  59. package/template/src/prototypes/getting-started/welcome/Welcome.tsx +74 -0
  60. package/template/src/prototypes/getting-started/welcome/data.ts +14 -0
  61. package/template/src/prototypes/getting-started/welcome/index.ts +2 -0
  62. package/template/src/prototypes/getting-started/welcome/welcome.css +71 -0
  63. package/template/src/prototypes/getting-started/welcome/workflows.ts +51 -0
  64. package/template/src/prototypes/registry.ts +20 -0
  65. package/template/src/prototypes/workflows.ts +9 -0
  66. package/template/tsconfig.app.json +28 -0
  67. package/template/tsconfig.json +7 -0
  68. package/template/tsconfig.node.json +26 -0
  69. package/template/vercel.json +4 -0
  70. package/template/vite.config.ts +12 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SASU STARCIER
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,57 @@
1
- # Temporary Holding Version
1
+ # @starcier/create-builder
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
+ Creates a Starcier prototype builder for a client in one command: it registers the client's
4
+ tenant with the Starcier API, then writes a repository with a starter design system, an example
5
+ prototype, and the authoring tooling for coding agents. Comments, guided tours and sharing come
6
+ from the `@starcier/*` packages, installed as dependencies.
7
+
8
+ Creating the tenant needs a **Starcier staff account**. Anyone can create the files alone with
9
+ `--offline`.
10
+
11
+ ## Use
12
+
13
+ ```bash
14
+ npm create @starcier/builder@next
15
+ ```
16
+
17
+ It asks for the client's name, the tenant slug (suggested from the name) and the folder, then
18
+ signs you in through your browser and creates the tenant. Pass anything up front as options,
19
+ after `--`:
20
+
21
+ ```bash
22
+ npm create @starcier/builder@next acme-builder -- --name "Acme" --slug acme
23
+ ```
24
+
25
+ Until `1.0.0` is released, use `@next` as above.
26
+
27
+ ### Connection settings
28
+
29
+ These do not change between clients, so set them once as environment variables rather than
30
+ passing them every time:
31
+
32
+ | Variable | Option | What |
33
+ | --- | --- | --- |
34
+ | `STARCIER_API_URL` | `--api-url` | The API's origin |
35
+ | `STARCIER_AUTH0_DOMAIN` | `--auth0-domain` | The Auth0 tenant's domain |
36
+ | `STARCIER_AUTH0_AUDIENCE` | `--auth0-audience` | The API's audience; defaults to `https://starcier-api` |
37
+ | `STARCIER_AUTH0_CLIENT_ID` | `--auth0-client-id` | The builders' single-page application, written into the builder's `.env` |
38
+ | `STARCIER_AUTH0_CLI_CLIENT_ID` | `--cli-client-id` | The native application this command signs in with |
39
+ | `STARCIER_TOKEN` | `--token` | An API access token, used instead of signing in |
40
+
41
+ ## What it guarantees
42
+
43
+ - **Nothing half-created.** The files are prepared aside and moved into place only after the
44
+ tenant exists. If the tenant cannot be created — not staff, slug taken, API unreachable — no
45
+ folder is left behind.
46
+ - **A taken slug is never altered.** You are asked for another one; without a terminal, the
47
+ command stops.
48
+ - **The tenant is the client's identity, not their domain.** The builder's address is set later,
49
+ once it is deployed; the generated README explains the steps.
50
+
51
+ ## Options
52
+
53
+ Run `npm create @starcier/builder@next -- --help` for the full list.
54
+
55
+ ## License
56
+
57
+ MIT
package/dist/api.js ADDED
@@ -0,0 +1,44 @@
1
+ /** The API origin as entered, without a trailing slash or `/v1`. */
2
+ export function normaliseApiUrl(value) {
3
+ return value.trim().replace(/\/+$/, '').replace(/\/v1$/, '');
4
+ }
5
+ function messageOf(body, fallback) {
6
+ const message = body?.message;
7
+ if (Array.isArray(message))
8
+ return message.join('; ');
9
+ return typeof message === 'string' && message ? message : fallback;
10
+ }
11
+ /**
12
+ * `POST /v1/tenants` (MAP-20). Staff only (ADR D11); the caller becomes the
13
+ * tenant's first owner. Never throws: every outcome is a result, so the
14
+ * caller decides what can be retried.
15
+ */
16
+ export async function createTenant(apiUrl, token, input, fetchFn = fetch) {
17
+ let response;
18
+ try {
19
+ response = await fetchFn(`${normaliseApiUrl(apiUrl)}/v1/tenants`, {
20
+ method: 'POST',
21
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
22
+ body: JSON.stringify(input),
23
+ });
24
+ }
25
+ catch (err) {
26
+ return { ok: false, reason: 'unreachable', message: err instanceof Error ? err.message : String(err) };
27
+ }
28
+ const body = await response.json().catch(() => null);
29
+ if (response.ok)
30
+ return { ok: true, tenant: body };
31
+ const message = messageOf(body, `${response.status} ${response.statusText}`.trim());
32
+ switch (response.status) {
33
+ case 401:
34
+ return { ok: false, reason: 'unauthorized', message };
35
+ case 403:
36
+ return { ok: false, reason: 'forbidden', message };
37
+ case 409:
38
+ return { ok: false, reason: 'conflict', message };
39
+ case 400:
40
+ return { ok: false, reason: 'invalid', message };
41
+ default:
42
+ return { ok: false, reason: 'unexpected', message };
43
+ }
44
+ }
package/dist/args.js ADDED
@@ -0,0 +1,77 @@
1
+ import { CliError } from './errors.js';
2
+ const VALUE_FLAGS = {
3
+ '--name': 'name',
4
+ '--slug': 'slug',
5
+ '--api-url': 'apiUrl',
6
+ '--auth0-domain': 'auth0Domain',
7
+ '--auth0-audience': 'auth0Audience',
8
+ '--auth0-client-id': 'spaClientId',
9
+ '--cli-client-id': 'cliClientId',
10
+ '--token': 'token',
11
+ };
12
+ /** Environment variables an operator can set once instead of passing flags every time. */
13
+ export const ENV_VARS = {
14
+ apiUrl: 'STARCIER_API_URL',
15
+ auth0Domain: 'STARCIER_AUTH0_DOMAIN',
16
+ auth0Audience: 'STARCIER_AUTH0_AUDIENCE',
17
+ spaClientId: 'STARCIER_AUTH0_CLIENT_ID',
18
+ cliClientId: 'STARCIER_AUTH0_CLI_CLIENT_ID',
19
+ token: 'STARCIER_TOKEN',
20
+ };
21
+ /** The flag that sets `key`, for messages. */
22
+ export function flagFor(key) {
23
+ return Object.entries(VALUE_FLAGS).find(([, value]) => value === key)?.[0] ?? key;
24
+ }
25
+ /** Flags win over environment variables; empty values count as unset. */
26
+ export function parseArgs(argv, env) {
27
+ const options = { offline: false, help: false };
28
+ for (const [key, variable] of Object.entries(ENV_VARS)) {
29
+ const value = env[variable]?.trim();
30
+ if (value)
31
+ options[key] = value;
32
+ }
33
+ for (let i = 0; i < argv.length; i++) {
34
+ const arg = argv[i];
35
+ if (arg === '--help' || arg === '-h') {
36
+ options.help = true;
37
+ }
38
+ else if (arg === '--offline') {
39
+ options.offline = true;
40
+ }
41
+ else if (arg.startsWith('--')) {
42
+ const [flag, inline] = arg.split(/=(.*)/s, 2);
43
+ const key = VALUE_FLAGS[flag];
44
+ if (!key)
45
+ throw new CliError(`Unknown option ${flag}. Run with --help to see the options.`);
46
+ const value = inline ?? argv[++i];
47
+ if (value === undefined || value.startsWith('--'))
48
+ throw new CliError(`${flag} needs a value.`);
49
+ options[key] = value.trim();
50
+ }
51
+ else if (options.dir === undefined) {
52
+ options.dir = arg;
53
+ }
54
+ else {
55
+ throw new CliError(`Unexpected argument "${arg}". Only one folder can be given.`);
56
+ }
57
+ }
58
+ return options;
59
+ }
60
+ export const USAGE = `Create a Starcier prototype builder for a client.
61
+
62
+ Usage: npm create @starcier/builder [folder] -- [options]
63
+
64
+ Options:
65
+ --name <name> The client's name, shown in the builder
66
+ --slug <slug> The tenant slug (suggested from the name)
67
+ --api-url <url> The Starcier API's origin env: STARCIER_API_URL
68
+ --auth0-domain <domain> The Auth0 tenant's domain env: STARCIER_AUTH0_DOMAIN
69
+ --auth0-audience <id> The API's Auth0 audience env: STARCIER_AUTH0_AUDIENCE
70
+ --auth0-client-id <id> The builders' Auth0 SPA client id env: STARCIER_AUTH0_CLIENT_ID
71
+ --cli-client-id <id> This command's Auth0 client id env: STARCIER_AUTH0_CLI_CLIENT_ID
72
+ --token <token> An API access token, to skip sign-in env: STARCIER_TOKEN
73
+ --offline Create the files only: no sign-in, no tenant
74
+ -h, --help Show this help
75
+
76
+ Creating the tenant needs a Starcier staff account. Anything not given is asked for.
77
+ `;
package/dist/auth.js ADDED
@@ -0,0 +1,68 @@
1
+ import { CliError } from './errors.js';
2
+ const origin = (domain) => `https://${domain.trim().replace(/^https?:\/\//, '').replace(/\/+$/, '')}`;
3
+ const form = (fields) => ({
4
+ method: 'POST',
5
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
6
+ body: new URLSearchParams(fields).toString(),
7
+ });
8
+ export async function requestDeviceCode(config, fetchFn = fetch) {
9
+ let response;
10
+ try {
11
+ response = await fetchFn(`${origin(config.domain)}/oauth/device/code`, form({ client_id: config.clientId, scope: 'openid profile email', audience: config.audience }));
12
+ }
13
+ catch (err) {
14
+ throw new CliError(`Could not reach Auth0 at ${origin(config.domain)}: ${err.message}`);
15
+ }
16
+ const body = (await response.json().catch(() => ({})));
17
+ if (!response.ok) {
18
+ throw new CliError(`Auth0 refused to start sign-in: ${String(body.error_description ?? body.error ?? response.status)}. ` +
19
+ 'Check the client id belongs to a native application with the Device Code grant enabled.');
20
+ }
21
+ return {
22
+ deviceCode: String(body.device_code),
23
+ userCode: String(body.user_code),
24
+ verificationUri: String(body.verification_uri),
25
+ verificationUriComplete: body.verification_uri_complete ? String(body.verification_uri_complete) : undefined,
26
+ interval: Number(body.interval) || 5,
27
+ expiresIn: Number(body.expires_in) || 900,
28
+ };
29
+ }
30
+ /** Waits for the operator to confirm in the browser, then returns the access token. */
31
+ export async function pollForToken(config, code, deps = {}) {
32
+ const fetchFn = deps.fetch ?? fetch;
33
+ const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
34
+ const now = deps.now ?? Date.now;
35
+ const deadline = now() + code.expiresIn * 1000;
36
+ let interval = code.interval;
37
+ while (now() < deadline) {
38
+ await sleep(interval * 1000);
39
+ let response;
40
+ try {
41
+ response = await fetchFn(`${origin(config.domain)}/oauth/token`, form({
42
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
43
+ device_code: code.deviceCode,
44
+ client_id: config.clientId,
45
+ }));
46
+ }
47
+ catch {
48
+ continue; // a dropped request is retried at the next interval
49
+ }
50
+ const body = (await response.json().catch(() => ({})));
51
+ if (response.ok && typeof body.access_token === 'string')
52
+ return body.access_token;
53
+ switch (body.error) {
54
+ case 'authorization_pending':
55
+ break;
56
+ case 'slow_down':
57
+ interval += 5;
58
+ break;
59
+ case 'expired_token':
60
+ throw new CliError('The sign-in code expired before it was confirmed. Run the command again.');
61
+ case 'access_denied':
62
+ throw new CliError('Sign-in was declined in the browser.');
63
+ default:
64
+ throw new CliError(`Sign-in failed: ${String(body.error_description ?? body.error ?? response.status)}.`);
65
+ }
66
+ }
67
+ throw new CliError('The sign-in code expired before it was confirmed. Run the command again.');
68
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env node
2
+ import { fileURLToPath } from 'node:url';
3
+ import { CliError } from './errors.js';
4
+ import { run } from './main.js';
5
+ import { terminalPrompter } from './prompt.js';
6
+ const prompter = terminalPrompter();
7
+ run({
8
+ argv: process.argv.slice(2),
9
+ env: process.env,
10
+ cwd: process.cwd(),
11
+ // Copied next to `dist` when the package is built (scripts/copy-template.mjs).
12
+ templateDir: fileURLToPath(new URL('../template', import.meta.url)),
13
+ prompter,
14
+ log: (line) => console.log(line),
15
+ })
16
+ .then((code) => {
17
+ process.exitCode = code;
18
+ })
19
+ .catch((err) => {
20
+ console.error(err instanceof CliError ? err.message : err);
21
+ process.exitCode = 1;
22
+ })
23
+ .finally(() => prompter.close());
package/dist/errors.js ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * A failure the operator can act on. Its message is printed as-is, without a
3
+ * stack trace, so it must say what happened and what to do next.
4
+ */
5
+ export class CliError extends Error {
6
+ constructor(message) {
7
+ super(message);
8
+ this.name = 'CliError';
9
+ }
10
+ }
package/dist/main.js ADDED
@@ -0,0 +1,164 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { mkdir, mkdtemp, readdir, rename, rm, rmdir } from 'node:fs/promises';
3
+ import { basename, dirname, join, relative, resolve } from 'node:path';
4
+ import { createTenant, normaliseApiUrl } from './api.js';
5
+ import { flagFor, parseArgs, USAGE } from './args.js';
6
+ import { pollForToken, requestDeviceCode } from './auth.js';
7
+ import { CliError } from './errors.js';
8
+ import { slugProblem, suggestSlug } from './slug.js';
9
+ import { configureBuilder, renderTemplate } from './template.js';
10
+ const DEFAULT_AUDIENCE = 'https://starcier-api';
11
+ const defaultGitInit = (dir) => {
12
+ spawnSync('git', ['init', '--quiet'], { cwd: dir, stdio: 'ignore' });
13
+ };
14
+ async function askValid(prompter, question, problem) {
15
+ for (;;) {
16
+ const value = await prompter.ask(question);
17
+ const issue = problem(value);
18
+ if (!issue)
19
+ return value;
20
+ if (!prompter.interactive)
21
+ throw new CliError(`${question.message}: ${issue}`);
22
+ console.error(issue);
23
+ }
24
+ }
25
+ const required = (label) => (value) => (value ? null : `${label} is required.`);
26
+ function urlProblem(value) {
27
+ try {
28
+ const url = new URL(value);
29
+ return url.protocol === 'https:' || url.protocol === 'http:' ? null : 'Use an http(s) address.';
30
+ }
31
+ catch {
32
+ return 'That is not a valid address.';
33
+ }
34
+ }
35
+ async function assertUsable(target) {
36
+ const entries = await readdir(target).catch((err) => {
37
+ if (err.code === 'ENOENT')
38
+ return [];
39
+ throw err;
40
+ });
41
+ if (entries.length > 0)
42
+ throw new CliError(`${target} is not empty. Choose a new folder.`);
43
+ }
44
+ /** Everything the online path needs, from flags, environment variables or questions. */
45
+ async function resolveConnection(options, prompter) {
46
+ const value = (key, message, check = required(message)) => options[key] ? Promise.resolve(options[key]) : askValid(prompter, { message, flag: flagFor(key) }, check);
47
+ const apiUrl = normaliseApiUrl(await value('apiUrl', 'Starcier API address', (v) => required('The API address')(v) ?? urlProblem(v)));
48
+ const auth0Domain = await value('auth0Domain', 'Auth0 domain');
49
+ const auth0Audience = options.auth0Audience || DEFAULT_AUDIENCE;
50
+ const spaClientId = await value('spaClientId', "The builders' Auth0 client id");
51
+ const cliClientId = options.token ? undefined : await value('cliClientId', "This command's Auth0 client id");
52
+ return { apiUrl, auth0Domain, auth0Audience, spaClientId, cliClientId };
53
+ }
54
+ async function signIn(config, deps) {
55
+ const auth = { domain: config.auth0Domain, clientId: config.cliClientId, audience: config.auth0Audience };
56
+ const code = await requestDeviceCode(auth, deps.fetch);
57
+ deps.log('');
58
+ deps.log(`Sign in with your Starcier account: ${code.verificationUriComplete ?? code.verificationUri}`);
59
+ deps.log(`Confirm that the browser shows this code: ${code.userCode}`);
60
+ deps.log('Waiting for you to confirm…');
61
+ const token = await pollForToken(auth, code, { fetch: deps.fetch, sleep: deps.sleep });
62
+ deps.log('Signed in.');
63
+ return token;
64
+ }
65
+ /**
66
+ * Creates a builder. The order is what keeps a failure clean: the folder is
67
+ * prepared in a staging copy first, the tenant is created last among the steps
68
+ * that can fail, and only then do the files move into place. A tenant cannot be
69
+ * deleted through the API, so nothing that can fail runs after it except the
70
+ * move, whose failure is reported with the way out.
71
+ */
72
+ export async function run(deps) {
73
+ const options = parseArgs(deps.argv, deps.env);
74
+ if (options.help) {
75
+ deps.log(USAGE);
76
+ return 0;
77
+ }
78
+ const { prompter } = deps;
79
+ const name = options.name ||
80
+ (await askValid(prompter, { message: "The client's name", flag: '--name' }, required("The client's name")));
81
+ if (name.length > 200)
82
+ throw new CliError("The client's name must be at most 200 characters.");
83
+ let slug = options.slug
84
+ ? options.slug
85
+ : await askValid(prompter, { message: 'Tenant slug', default: suggestSlug(name) || undefined, flag: '--slug' }, (v) => slugProblem(v));
86
+ const problem = slugProblem(slug);
87
+ if (problem)
88
+ throw new CliError(`Slug "${slug}": ${problem}`);
89
+ const dir = options.dir || (await prompter.ask({ message: 'Folder', default: `${slug}-builder`, flag: 'a folder argument' }));
90
+ const target = resolve(deps.cwd, dir);
91
+ await assertUsable(target);
92
+ const connection = options.offline ? null : await resolveConnection(options, prompter);
93
+ await mkdir(dirname(target), { recursive: true });
94
+ const staging = await mkdtemp(join(dirname(target), `.${basename(target)}-`));
95
+ try {
96
+ await renderTemplate(deps.templateDir, staging, name);
97
+ let env = { VITE_TENANT_SLUG: slug };
98
+ if (connection) {
99
+ const token = options.token ||
100
+ (await signIn({ ...connection, cliClientId: connection.cliClientId }, deps));
101
+ for (;;) {
102
+ const result = await createTenant(connection.apiUrl, token, { slug, name }, deps.fetch);
103
+ if (result.ok) {
104
+ deps.log(`Created the tenant "${result.tenant.slug}".`);
105
+ break;
106
+ }
107
+ if (result.reason === 'conflict' && prompter.interactive) {
108
+ deps.log(`The slug "${slug}" is taken. Choose another.`);
109
+ slug = await askValid(prompter, { message: 'Tenant slug', flag: '--slug' }, (v) => slugProblem(v));
110
+ continue;
111
+ }
112
+ throw new CliError(explain(result.reason, result.message, slug, connection.apiUrl));
113
+ }
114
+ env = {
115
+ VITE_API_BASE_URL: connection.apiUrl,
116
+ VITE_TENANT_SLUG: slug,
117
+ VITE_AUTH0_DOMAIN: connection.auth0Domain.replace(/^https?:\/\//, '').replace(/\/+$/, ''),
118
+ VITE_AUTH0_CLIENT_ID: connection.spaClientId,
119
+ VITE_AUTH0_AUDIENCE: connection.auth0Audience,
120
+ };
121
+ }
122
+ await configureBuilder(staging, env);
123
+ }
124
+ catch (err) {
125
+ await rm(staging, { recursive: true, force: true });
126
+ throw err;
127
+ }
128
+ try {
129
+ await rmdir(target).catch(() => { }); // an empty folder the operator made in advance
130
+ await rename(staging, target);
131
+ }
132
+ catch (err) {
133
+ throw new CliError(`The tenant "${slug}" exists, but the files could not be moved to ${target}: ${err.message}. ` +
134
+ `They are complete in ${staging}; rename that folder yourself. Do not run this command again for the same client.`);
135
+ }
136
+ (deps.gitInit ?? defaultGitInit)(target);
137
+ const where = relative(deps.cwd, target) || '.';
138
+ deps.log('');
139
+ deps.log(`Created ${name}'s builder in ${where}${connection ? `, with the tenant "${slug}"` : ' (offline: no tenant)'}.`);
140
+ deps.log('');
141
+ deps.log('Next:');
142
+ deps.log(` cd ${where}`);
143
+ deps.log(' npm install');
144
+ deps.log(' npm run dev');
145
+ deps.log('');
146
+ deps.log('Then read "Going live" in its README: deploying, the base URL, and sign-in addresses.');
147
+ return 0;
148
+ }
149
+ function explain(reason, message, slug, apiUrl) {
150
+ switch (reason) {
151
+ case 'unauthorized':
152
+ return `The API did not accept the sign-in (${message}). Check the Auth0 audience matches the API's.`;
153
+ case 'forbidden':
154
+ return `${message}. Creating a builder needs a Starcier staff account; nothing was created.`;
155
+ case 'conflict':
156
+ return `The slug "${slug}" is already taken; nothing was created. Pass another with --slug.`;
157
+ case 'invalid':
158
+ return `The API rejected the request: ${message}. Nothing was created.`;
159
+ case 'unreachable':
160
+ return `Could not reach the API at ${apiUrl} (${message}). Nothing was created.`;
161
+ default:
162
+ return `The API answered unexpectedly: ${message}. Nothing was created.`;
163
+ }
164
+ }
package/dist/prompt.js ADDED
@@ -0,0 +1,23 @@
1
+ import { createInterface } from 'node:readline/promises';
2
+ import { CliError } from './errors.js';
3
+ /** Questions on the terminal; without one, a clear error naming the missing flag. */
4
+ export function terminalPrompter() {
5
+ const interactive = !!process.stdin.isTTY;
6
+ const rl = interactive ? createInterface({ input: process.stdin, output: process.stdout }) : null;
7
+ return {
8
+ interactive,
9
+ async ask({ message, default: fallback, flag }) {
10
+ if (!rl) {
11
+ if (fallback !== undefined)
12
+ return fallback;
13
+ throw new CliError(`${message} is required. Pass ${flag}, since there is no terminal to ask on.`);
14
+ }
15
+ const suffix = fallback ? ` (${fallback})` : '';
16
+ const answer = (await rl.question(`${message}${suffix}: `)).trim();
17
+ return answer || fallback || '';
18
+ },
19
+ close() {
20
+ rl?.close();
21
+ },
22
+ };
23
+ }
package/dist/slug.js ADDED
@@ -0,0 +1,20 @@
1
+ /** The API's own rule for a tenant slug (`TENANT_SLUG` in the API's tenant guard). */
2
+ export const SLUG_PATTERN = /^[a-z0-9-]{1,64}$/;
3
+ /** A slug suggested from the client's name: "Café Nord AB" → "cafe-nord-ab". */
4
+ export function suggestSlug(name) {
5
+ return name
6
+ .normalize('NFKD')
7
+ .replace(/[̀-ͯ]/g, '')
8
+ .toLowerCase()
9
+ .replace(/[^a-z0-9]+/g, '-')
10
+ .replace(/^-+|-+$/g, '')
11
+ .slice(0, 64)
12
+ .replace(/-+$/, '');
13
+ }
14
+ /** Why `slug` is not a valid tenant slug, or null when it is. */
15
+ export function slugProblem(slug) {
16
+ if (!SLUG_PATTERN.test(slug)) {
17
+ return 'A slug is 1–64 characters of lowercase letters, digits and hyphens.';
18
+ }
19
+ return null;
20
+ }
@@ -0,0 +1,61 @@
1
+ import { cp, readFile, rename, writeFile } from 'node:fs/promises';
2
+ import { basename, join } from 'node:path';
3
+ import { CliError } from './errors.js';
4
+ /** The client name the template is written with, replaced in `NAMED_FILES`. */
5
+ export const PLACEHOLDER_NAME = 'Example Client';
6
+ const escapeHtml = (value) => value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
7
+ const escapeSingleQuoted = (value) => value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
8
+ /** Where the name appears, and how it must be written in each place. */
9
+ const NAMED_FILES = {
10
+ 'index.html': (name) => [PLACEHOLDER_NAME, escapeHtml(name)],
11
+ 'README.md': (name) => [PLACEHOLDER_NAME, name],
12
+ 'src/brand.ts': (name) => [`'${PLACEHOLDER_NAME}'`, `'${escapeSingleQuoted(name)}'`],
13
+ };
14
+ /** Never copied: build output, installs, and local settings of the monorepo workspace. */
15
+ const SKIPPED = new Set(['node_modules', 'dist', '.env']);
16
+ const skipped = (path) => SKIPPED.has(basename(path)) || path.endsWith('.tsbuildinfo') || path.endsWith('.local');
17
+ /**
18
+ * Copies the template into `outDir` and makes it this client's: their name in
19
+ * the visible places, a real `.gitignore` (npm drops one from a published
20
+ * package, so the template ships `_gitignore`), and the skills in both tools'
21
+ * folders (Cursor reads `.cursor/skills`, Claude Code `.claude/skills`).
22
+ */
23
+ export async function renderTemplate(templateDir, outDir, name) {
24
+ await cp(templateDir, outDir, { recursive: true, filter: (source) => !skipped(source) });
25
+ await rename(join(outDir, '_gitignore'), join(outDir, '.gitignore'));
26
+ await cp(join(outDir, '.claude', 'skills'), join(outDir, '.cursor', 'skills'), { recursive: true });
27
+ for (const [file, replace] of Object.entries(NAMED_FILES)) {
28
+ const path = join(outDir, file);
29
+ const text = await readFile(path, 'utf8');
30
+ const [search, replacement] = replace(name);
31
+ if (!text.includes(search)) {
32
+ // The template changed without this list: fail rather than ship a builder named "Example Client".
33
+ throw new CliError(`The template's ${file} no longer contains "${search}"; update NAMED_FILES in create-builder.`);
34
+ }
35
+ await writeFile(path, text.split(search).join(replacement));
36
+ }
37
+ }
38
+ /** The `.env` a builder is created with. Committed: none of it is secret. */
39
+ export function envFile(env) {
40
+ const line = (key) => `${key}=${env[key] ?? ''}`;
41
+ return [
42
+ "# This builder's configuration, written by `npm create @starcier/builder`.",
43
+ '# None of it is secret: every VITE_ value is compiled into the browser bundle,',
44
+ '# so this file is committed. `.env.example` explains each value.',
45
+ '',
46
+ line('VITE_API_BASE_URL'),
47
+ line('VITE_TENANT_SLUG'),
48
+ line('VITE_AUTH0_DOMAIN'),
49
+ line('VITE_AUTH0_CLIENT_ID'),
50
+ line('VITE_AUTH0_AUDIENCE'),
51
+ '',
52
+ ].join('\n');
53
+ }
54
+ /** Writes what depends on the tenant slug, which is final only once the tenant exists. */
55
+ export async function configureBuilder(outDir, env) {
56
+ const manifestPath = join(outDir, 'package.json');
57
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
58
+ manifest.name = `${env.VITE_TENANT_SLUG}-builder`;
59
+ await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
60
+ await writeFile(join(outDir, '.env'), envFile(env));
61
+ }
package/package.json CHANGED
@@ -1,6 +1,36 @@
1
1
  {
2
2
  "name": "@starcier/create-builder",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0-next.0",
4
+ "description": "Creates a Starcier prototype builder for a client: registers its tenant with the Starcier API and writes a repository with a starter design system. Run with `npm create @starcier/builder`.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/starcier/starcier-platform.git",
9
+ "directory": "packages/create-builder"
10
+ },
11
+ "type": "module",
12
+ "bin": {
13
+ "create-builder": "dist/cli.js"
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "!dist/**/*.test.*",
18
+ "template"
19
+ ],
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "engines": {
24
+ "node": ">=20"
25
+ },
26
+ "scripts": {
27
+ "build": "node scripts/copy-template.mjs && tsc -p tsconfig.build.json",
28
+ "typecheck": "tsc -p tsconfig.json",
29
+ "test": "vitest run"
30
+ },
31
+ "devDependencies": {
32
+ "@types/node": "^24.10.1",
33
+ "typescript": "~5.9.3",
34
+ "vitest": "^4.0.18"
35
+ }
36
+ }