@starcier/create-builder 0.0.0-stage → 1.0.0-next.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.
- package/LICENSE +21 -0
- package/README.md +62 -2
- package/dist/api.js +44 -0
- package/dist/args.js +79 -0
- package/dist/auth.js +68 -0
- package/dist/cli.js +23 -0
- package/dist/defaults.js +15 -0
- package/dist/errors.js +10 -0
- package/dist/main.js +162 -0
- package/dist/prompt.js +23 -0
- package/dist/slug.js +20 -0
- package/dist/template.js +61 -0
- package/package.json +34 -4
- package/template/.claude/skills/design-system/SKILL.md +177 -0
- package/template/.claude/skills/design-system-audit/SKILL.md +125 -0
- package/template/.claude/skills/design-work-review/SKILL.md +93 -0
- package/template/.claude/skills/process-interviewer/SKILL.md +124 -0
- package/template/.claude/skills/process-interviewer/references/plan-output-template.md +53 -0
- package/template/.claude/skills/process-interviewer/references/skill-output-template.md +72 -0
- package/template/.claude/skills/prototype-page-builder/SKILL.md +76 -0
- package/template/.cursor/rules/code-review.mdc +30 -0
- package/template/.cursor/rules/prototype-authoring.mdc +31 -0
- package/template/.cursor/rules/tokens.mdc +36 -0
- package/template/.cursor/rules/ui-system.mdc +39 -0
- package/template/.env.example +19 -0
- package/template/AGENTS.md +77 -0
- package/template/CLAUDE.md +19 -0
- package/template/README.md +65 -0
- package/template/_gitignore +15 -0
- package/template/docs/design-system-log.md +17 -0
- package/template/eslint.config.js +23 -0
- package/template/index.html +13 -0
- package/template/package.json +35 -0
- package/template/public/favicon.svg +1 -0
- package/template/src/App.tsx +65 -0
- package/template/src/app.css +5 -0
- package/template/src/brand.ts +2 -0
- package/template/src/config.ts +15 -0
- package/template/src/design-system/ThemeProvider.tsx +25 -0
- package/template/src/design-system/components/Button/Button.tsx +15 -0
- package/template/src/design-system/components/Button/button.css +62 -0
- package/template/src/design-system/components/Button/index.ts +1 -0
- package/template/src/design-system/components/Card/Card.tsx +22 -0
- package/template/src/design-system/components/Card/card.css +27 -0
- package/template/src/design-system/components/Card/index.ts +1 -0
- package/template/src/design-system/components/Field/Field.tsx +28 -0
- package/template/src/design-system/components/Field/field.css +37 -0
- package/template/src/design-system/components/Field/index.ts +1 -0
- package/template/src/design-system/components/Modal/Modal.tsx +55 -0
- package/template/src/design-system/components/Modal/index.ts +1 -0
- package/template/src/design-system/components/Modal/modal.css +39 -0
- package/template/src/design-system/components/ThemeToggle/ThemeToggle.tsx +13 -0
- package/template/src/design-system/components/ThemeToggle/index.ts +1 -0
- package/template/src/design-system/global.css +26 -0
- package/template/src/design-system/palette.ts +40 -0
- package/template/src/design-system/theme.ts +55 -0
- package/template/src/design-system/tokens.ts +126 -0
- package/template/src/main.tsx +13 -0
- package/template/src/prototypes/getting-started/welcome/README.md +6 -0
- package/template/src/prototypes/getting-started/welcome/Welcome.tsx +74 -0
- package/template/src/prototypes/getting-started/welcome/data.ts +14 -0
- package/template/src/prototypes/getting-started/welcome/index.ts +2 -0
- package/template/src/prototypes/getting-started/welcome/welcome.css +71 -0
- package/template/src/prototypes/getting-started/welcome/workflows.ts +51 -0
- package/template/src/prototypes/registry.ts +20 -0
- package/template/src/prototypes/workflows.ts +9 -0
- package/template/tsconfig.app.json +28 -0
- package/template/tsconfig.json +7 -0
- package/template/tsconfig.node.json +26 -0
- package/template/vercel.json +4 -0
- 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,63 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @starcier/create-builder
|
|
2
2
|
|
|
3
|
-
|
|
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 they are **built in** and you never type them. Override
|
|
30
|
+
one only to point somewhere else, such as a local API, with an option or an environment variable:
|
|
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 |
|
|
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
|
+
The built-in values point at Starcier's dev environment until production goes live.
|
|
42
|
+
|
|
43
|
+
**Signing in.** Until the command's own Auth0 application exists, it cannot sign you in through
|
|
44
|
+
the browser, so it needs your access token: sign in to the Starcier builder, copy the token, and
|
|
45
|
+
set it as `STARCIER_TOKEN` for the terminal you run the command in.
|
|
46
|
+
|
|
47
|
+
## What it guarantees
|
|
48
|
+
|
|
49
|
+
- **Nothing half-created.** The files are prepared aside and moved into place only after the
|
|
50
|
+
tenant exists. If the tenant cannot be created — not staff, slug taken, API unreachable — no
|
|
51
|
+
folder is left behind.
|
|
52
|
+
- **A taken slug is never altered.** You are asked for another one; without a terminal, the
|
|
53
|
+
command stops.
|
|
54
|
+
- **The tenant is the client's identity, not their domain.** The builder's address is set later,
|
|
55
|
+
once it is deployed; the generated README explains the steps.
|
|
56
|
+
|
|
57
|
+
## Options
|
|
58
|
+
|
|
59
|
+
Run `npm create @starcier/builder@next -- --help` for the full list.
|
|
60
|
+
|
|
61
|
+
## License
|
|
62
|
+
|
|
63
|
+
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,79 @@
|
|
|
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
|
+
The name, slug and folder are asked for when not given. The API and Auth0
|
|
77
|
+
settings are built in, so leave them out unless pointing somewhere else.
|
|
78
|
+
Creating the tenant needs a Starcier staff account.
|
|
79
|
+
`;
|
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/defaults.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a new builder connects when nothing else is given. These are the same
|
|
3
|
+
* for every client, so an operator never has to know or type them; flags and
|
|
4
|
+
* `STARCIER_*` variables still win, e.g. to point at a local API.
|
|
5
|
+
*
|
|
6
|
+
* Today they are the dev environment's, the only one that exists. At go-live
|
|
7
|
+
* they become production's (deploy-to-production.md, "Client builders").
|
|
8
|
+
*/
|
|
9
|
+
export const DEFAULTS = {
|
|
10
|
+
apiUrl: 'https://starcier-design-system-dev.up.railway.app',
|
|
11
|
+
auth0Domain: 'dev-k7pyqn8upxwm2wgi.us.auth0.com',
|
|
12
|
+
auth0Audience: 'https://starcier-design-system-api',
|
|
13
|
+
spaClientId: 'tqVw1OE5L4ElaTeKtnKfynBr2rIWNeY7',
|
|
14
|
+
cliClientId: undefined,
|
|
15
|
+
};
|
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,162 @@
|
|
|
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 { DEFAULTS } from './defaults.js';
|
|
8
|
+
import { CliError } from './errors.js';
|
|
9
|
+
import { slugProblem, suggestSlug } from './slug.js';
|
|
10
|
+
import { configureBuilder, renderTemplate } from './template.js';
|
|
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
|
+
async function assertUsable(target) {
|
|
27
|
+
const entries = await readdir(target).catch((err) => {
|
|
28
|
+
if (err.code === 'ENOENT')
|
|
29
|
+
return [];
|
|
30
|
+
throw err;
|
|
31
|
+
});
|
|
32
|
+
if (entries.length > 0)
|
|
33
|
+
throw new CliError(`${target} is not empty. Choose a new folder.`);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Everything the online path needs: flags and environment variables, else the
|
|
37
|
+
* built-in defaults, so nothing here is ever asked. Signing in needs either a
|
|
38
|
+
* token or the command's own Auth0 application.
|
|
39
|
+
*/
|
|
40
|
+
function resolveConnection(options) {
|
|
41
|
+
const apiUrl = normaliseApiUrl(options.apiUrl || DEFAULTS.apiUrl);
|
|
42
|
+
const auth0Domain = options.auth0Domain || DEFAULTS.auth0Domain;
|
|
43
|
+
const auth0Audience = options.auth0Audience || DEFAULTS.auth0Audience;
|
|
44
|
+
const spaClientId = options.spaClientId || DEFAULTS.spaClientId;
|
|
45
|
+
const cliClientId = options.token ? undefined : options.cliClientId || DEFAULTS.cliClientId;
|
|
46
|
+
if (!options.token && !cliClientId) {
|
|
47
|
+
throw new CliError('Signing in through the browser is not set up yet. Sign in to the Starcier builder, copy your access ' +
|
|
48
|
+
`token, and pass it with ${flagFor('token')} or the STARCIER_TOKEN environment variable.`);
|
|
49
|
+
}
|
|
50
|
+
return { apiUrl, auth0Domain, auth0Audience, spaClientId, cliClientId };
|
|
51
|
+
}
|
|
52
|
+
async function signIn(config, deps) {
|
|
53
|
+
const auth = { domain: config.auth0Domain, clientId: config.cliClientId, audience: config.auth0Audience };
|
|
54
|
+
const code = await requestDeviceCode(auth, deps.fetch);
|
|
55
|
+
deps.log('');
|
|
56
|
+
deps.log(`Sign in with your Starcier account: ${code.verificationUriComplete ?? code.verificationUri}`);
|
|
57
|
+
deps.log(`Confirm that the browser shows this code: ${code.userCode}`);
|
|
58
|
+
deps.log('Waiting for you to confirm…');
|
|
59
|
+
const token = await pollForToken(auth, code, { fetch: deps.fetch, sleep: deps.sleep });
|
|
60
|
+
deps.log('Signed in.');
|
|
61
|
+
return token;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Creates a builder. The order is what keeps a failure clean: the folder is
|
|
65
|
+
* prepared in a staging copy first, the tenant is created last among the steps
|
|
66
|
+
* that can fail, and only then do the files move into place. A tenant cannot be
|
|
67
|
+
* deleted through the API, so nothing that can fail runs after it except the
|
|
68
|
+
* move, whose failure is reported with the way out.
|
|
69
|
+
*/
|
|
70
|
+
export async function run(deps) {
|
|
71
|
+
const options = parseArgs(deps.argv, deps.env);
|
|
72
|
+
if (options.help) {
|
|
73
|
+
deps.log(USAGE);
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
76
|
+
const { prompter } = deps;
|
|
77
|
+
const name = options.name ||
|
|
78
|
+
(await askValid(prompter, { message: "The client's name", flag: '--name' }, required("The client's name")));
|
|
79
|
+
if (name.length > 200)
|
|
80
|
+
throw new CliError("The client's name must be at most 200 characters.");
|
|
81
|
+
let slug = options.slug
|
|
82
|
+
? options.slug
|
|
83
|
+
: await askValid(prompter, { message: 'Tenant slug', default: suggestSlug(name) || undefined, flag: '--slug' }, (v) => slugProblem(v));
|
|
84
|
+
const problem = slugProblem(slug);
|
|
85
|
+
if (problem)
|
|
86
|
+
throw new CliError(`Slug "${slug}": ${problem}`);
|
|
87
|
+
const dir = options.dir || (await prompter.ask({ message: 'Folder', default: `${slug}-builder`, flag: 'a folder argument' }));
|
|
88
|
+
const target = resolve(deps.cwd, dir);
|
|
89
|
+
await assertUsable(target);
|
|
90
|
+
const connection = options.offline ? null : resolveConnection(options);
|
|
91
|
+
await mkdir(dirname(target), { recursive: true });
|
|
92
|
+
const staging = await mkdtemp(join(dirname(target), `.${basename(target)}-`));
|
|
93
|
+
try {
|
|
94
|
+
await renderTemplate(deps.templateDir, staging, name);
|
|
95
|
+
let env = { VITE_TENANT_SLUG: slug };
|
|
96
|
+
if (connection) {
|
|
97
|
+
const token = options.token ||
|
|
98
|
+
(await signIn({ ...connection, cliClientId: connection.cliClientId }, deps));
|
|
99
|
+
for (;;) {
|
|
100
|
+
const result = await createTenant(connection.apiUrl, token, { slug, name }, deps.fetch);
|
|
101
|
+
if (result.ok) {
|
|
102
|
+
deps.log(`Created the tenant "${result.tenant.slug}".`);
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
if (result.reason === 'conflict' && prompter.interactive) {
|
|
106
|
+
deps.log(`The slug "${slug}" is taken. Choose another.`);
|
|
107
|
+
slug = await askValid(prompter, { message: 'Tenant slug', flag: '--slug' }, (v) => slugProblem(v));
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
throw new CliError(explain(result.reason, result.message, slug, connection.apiUrl));
|
|
111
|
+
}
|
|
112
|
+
env = {
|
|
113
|
+
VITE_API_BASE_URL: connection.apiUrl,
|
|
114
|
+
VITE_TENANT_SLUG: slug,
|
|
115
|
+
VITE_AUTH0_DOMAIN: connection.auth0Domain.replace(/^https?:\/\//, '').replace(/\/+$/, ''),
|
|
116
|
+
VITE_AUTH0_CLIENT_ID: connection.spaClientId,
|
|
117
|
+
VITE_AUTH0_AUDIENCE: connection.auth0Audience,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
await configureBuilder(staging, env);
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
await rm(staging, { recursive: true, force: true });
|
|
124
|
+
throw err;
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
await rmdir(target).catch(() => { }); // an empty folder the operator made in advance
|
|
128
|
+
await rename(staging, target);
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
throw new CliError(`The tenant "${slug}" exists, but the files could not be moved to ${target}: ${err.message}. ` +
|
|
132
|
+
`They are complete in ${staging}; rename that folder yourself. Do not run this command again for the same client.`);
|
|
133
|
+
}
|
|
134
|
+
(deps.gitInit ?? defaultGitInit)(target);
|
|
135
|
+
const where = relative(deps.cwd, target) || '.';
|
|
136
|
+
deps.log('');
|
|
137
|
+
deps.log(`Created ${name}'s builder in ${where}${connection ? `, with the tenant "${slug}"` : ' (offline: no tenant)'}.`);
|
|
138
|
+
deps.log('');
|
|
139
|
+
deps.log('Next:');
|
|
140
|
+
deps.log(` cd ${where}`);
|
|
141
|
+
deps.log(' npm install');
|
|
142
|
+
deps.log(' npm run dev');
|
|
143
|
+
deps.log('');
|
|
144
|
+
deps.log('Then read "Going live" in its README: deploying, the base URL, and sign-in addresses.');
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
|
147
|
+
function explain(reason, message, slug, apiUrl) {
|
|
148
|
+
switch (reason) {
|
|
149
|
+
case 'unauthorized':
|
|
150
|
+
return `The API did not accept the sign-in (${message}). Check the Auth0 audience matches the API's.`;
|
|
151
|
+
case 'forbidden':
|
|
152
|
+
return `${message}. Creating a builder needs a Starcier staff account; nothing was created.`;
|
|
153
|
+
case 'conflict':
|
|
154
|
+
return `The slug "${slug}" is already taken; nothing was created. Pass another with --slug.`;
|
|
155
|
+
case 'invalid':
|
|
156
|
+
return `The API rejected the request: ${message}. Nothing was created.`;
|
|
157
|
+
case 'unreachable':
|
|
158
|
+
return `Could not reach the API at ${apiUrl} (${message}). Nothing was created.`;
|
|
159
|
+
default:
|
|
160
|
+
return `The API answered unexpectedly: ${message}. Nothing was created.`;
|
|
161
|
+
}
|
|
162
|
+
}
|
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
|
+
}
|
package/dist/template.js
ADDED
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0-next.1",
|
|
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
|
+
}
|