@saastemly/voidcommerce 0.18.0 → 0.19.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.
@@ -47,14 +47,13 @@ export function renderDistWorkflow(manifest: Manifest): string {
47
47
  # Cloudflare. The check runs FIRST and on its own: nothing is built, created
48
48
  # or replaced for a shop that is not ready.
49
49
  #
50
- # The credentials come from the repository, set once by \`vc link\`:
50
+ # ONE thing is configured outside the repository:
51
51
  #
52
- # secrets.CLOUDFLARE_API_TOKEN deploys, and creates D1 + the queue
53
- # vars.${PRIVATE_KEY_VAR} opens ${SECRETS_FILE} (a VARIABLE, so it can be read back)
54
- # vars.CLOUDFLARE_ACCOUNT_ID which account (an id, not a credential)
52
+ # vars.${PRIVATE_KEY_VAR} the key that opens ${SECRETS_FILE}
55
53
  #
56
- # Nothing here needs a Cloudflare dashboard visit and nothing needs wrangler
57
- # on your machine. See DEPLOY.md.
54
+ # Everything else including the Cloudflare token and account id — is an
55
+ # ordinary secret in that file, encrypted and committed, so it travels with
56
+ # the push. See DEPLOY.md.
58
57
  on:
59
58
  push:
60
59
  branches: [main, master]
@@ -145,8 +144,8 @@ jobs:
145
144
  name: production
146
145
  url: https://${manifest.shop.domain}
147
146
  env:
148
- CLOUDFLARE_API_TOKEN: \${{ secrets.CLOUDFLARE_API_TOKEN }}
149
- CLOUDFLARE_ACCOUNT_ID: \${{ vars.CLOUDFLARE_ACCOUNT_ID }}
147
+ # The only thing this deploy is given. The Cloudflare credentials are
148
+ # read out of .env.secrets, which this key opens.
150
149
  ${PRIVATE_KEY_VAR}: \${{ vars.${PRIVATE_KEY_VAR} }}
151
150
  steps:
152
151
  - uses: actions/checkout@v6
@@ -160,19 +159,15 @@ jobs:
160
159
  # that is what makes a new laptop or a second person possible — but
161
160
  # GitHub only masks SECRETS in logs automatically. Registering it with
162
161
  # ::add-mask:: buys back the redaction without giving up recovery.
163
- - name: Are the credentials here?
162
+ - name: Is the key here?
164
163
  run: |
165
164
  set -euo pipefail
166
- if [ -n "\${${PRIVATE_KEY_VAR}:-}" ]; then
167
- echo "::add-mask::\${${PRIVATE_KEY_VAR}}"
168
- fi
169
- missing=""
170
- [ -n "\${CLOUDFLARE_API_TOKEN:-}" ] || missing="$missing CLOUDFLARE_API_TOKEN"
171
- [ -n "\${${PRIVATE_KEY_VAR}:-}" ] || missing="$missing ${PRIVATE_KEY_VAR}"
172
- if [ -n "$missing" ]; then
173
- echo "::error::this repository has no$missing. Run 'vc link' once, from a checkout."
165
+ if [ -z "\${${PRIVATE_KEY_VAR}:-}" ]; then
166
+ echo "::error::this repository has no ${PRIVATE_KEY_VAR} variable, so .env.secrets cannot be opened and nothing can deploy."
167
+ echo "Set it once, from a checkout: gh variable set ${PRIVATE_KEY_VAR}"
174
168
  exit 1
175
169
  fi
170
+ echo "::add-mask::\${${PRIVATE_KEY_VAR}}"
176
171
 
177
172
  - run: bun install --frozen-lockfile
178
173
 
@@ -231,53 +226,26 @@ below.
231
226
  ## Once, to start
232
227
 
233
228
  \`\`\`sh
234
- vc publish
229
+ gh repo create --source=. --private # or make it in the browser
230
+ vc secrets --init # writes the key onto the repository
231
+ vc secrets set CLOUDFLARE_API_TOKEN # and every other key it lists
232
+ git push -u origin main
235
233
  \`\`\`
236
234
 
237
- One command: it creates the repository, gives it what the deploy needs, and
238
- pushes — and the push is the deploy. After it, \`git push\` is the whole loop.
239
-
240
- Two of the three things it sets need nothing from you. The encryption key vc
241
- already holds; the account id is in \`voidcommerce.json\`. It asks only for a
242
- Cloudflare API token, because there is no OIDC between GitHub and Cloudflare
243
- and only a person can fetch one from the dashboard.
244
-
245
- It stores three things on the GitHub repository:
246
-
247
- | what | where | why |
248
- |---|---|---|
249
- | \`${PRIVATE_KEY_VAR}\` | repository **variable** | opens \`${SECRETS_FILE}\`. A variable, not a secret, so you can get it back — see below |
250
- | \`CLOUDFLARE_API_TOKEN\` | repository **secret** | deploys, and creates D1 and the queue |
251
- | \`CLOUDFLARE_ACCOUNT_ID\` | repository **variable** | which account. An identifier, not a credential |
252
-
253
- It will ask you to paste a Cloudflare API token. That is the only manual step
254
- in the whole setup, and it is worth saying exactly why it cannot be removed:
255
-
256
- > **GitHub cannot mint a Cloudflare credential.** There is no OIDC or workload
257
- > identity federation between them — the feature request has been open since
258
- > 2025 with no commitment, and Cloudflare's own CI guidance still says to store
259
- > a token in your CI provider's secrets. The Cloudflare GitHub App does not
260
- > help either: it grants *Cloudflare* access to your *repository*, not the
261
- > reverse. Something has to authorise creating a database in your account, and
262
- > only Cloudflare can issue that authorisation.
263
-
264
- Create the token at **My Profile → API Tokens → Create Token → Custom token**:
265
-
266
- | scope | permission | for |
267
- |---|---|---|
268
- | Account | Workers Scripts: Edit | deploying the worker |
269
- | Account | D1: Edit | creating the database, applying migrations |
270
- | Account | Queues: Edit | the order queue |
271
- | Account | Workers KV Storage: Edit | sessions and caches |
272
- | Account | Workers R2 Storage: Edit | product images |
273
- | Account | Account Settings: Read | confirming which account |
274
- | Zone | Workers Routes: Edit (${zone}) | answering on your domain |
275
- | Zone | Zone: Read (${zone}) | confirming the zone is on this account |
276
- | Zone | DNS: Read (${zone}) | noticing a hostname that already answers |
277
- | User | User Details: Read, Memberships: Read | wrangler asks at startup |
278
-
279
- The token Cloudflare generates for its own Workers Builds will **not** do: it
280
- has no D1 and no Queues permission, so it cannot create this shop's database.
235
+ That push is the deploy, and every push after it.
236
+
237
+ **Exactly one thing lives outside the repository**: \`${PRIVATE_KEY_VAR}\`, a
238
+ repository variable holding the key that opens \`${SECRETS_FILE}\`. It is set
239
+ for you the moment the key is made, as long as the repository already exists
240
+ which is why the repository comes first above.
241
+
242
+ Everything else, including the Cloudflare token and account id, is an
243
+ ordinary secret in \`${SECRETS_FILE}\`: encrypted, committed, and read by the
244
+ deploy out of what you pushed.
245
+
246
+ The token itself has to be fetched by a person, once, because there is no
247
+ OIDC between GitHub and Cloudflare and nothing else can issue one. Create it
248
+ at **My Profile API Tokens Create Token Custom token**:
281
249
 
282
250
  ### The zone
283
251
 
@@ -13,6 +13,28 @@ import { type Manifest, envKeysOf, has, hasFrontend, isApex, zone , oneOrigin} f
13
13
 
14
14
  /** Keys every shop has, whatever else was chosen. */
15
15
  const BASE: EnvKey[] = [
16
+ /**
17
+ * The Cloudflare credentials are ORDINARY SECRETS, deliberately.
18
+ *
19
+ * They used to be set on the GitHub repository by a `vc link` step, which
20
+ * existed only because they were treated as special. They are not: they
21
+ * are two more values this shop needs, they belong in `.env.secrets` with
22
+ * everything else, and they are set the same way — `vc secrets set`.
23
+ *
24
+ * That leaves exactly ONE thing that has to be configured outside the
25
+ * repository: the key that opens `.env.secrets`. Everything else, the
26
+ * deploy reads out of what was pushed.
27
+ */
28
+ {
29
+ key: "CLOUDFLARE_API_TOKEN",
30
+ breaks: "nothing deploys: no worker, no database, no migrations",
31
+ where: "dash.cloudflare.com/profile/api-tokens → Create Token → Custom token",
32
+ },
33
+ {
34
+ key: "CLOUDFLARE_ACCOUNT_ID",
35
+ breaks: "wrangler cannot tell which account to deploy into, and refuses rather than guessing",
36
+ where: "the Cloudflare dashboard sidebar, or `vc secrets set` it after the first provision",
37
+ },
16
38
  {
17
39
  key: "SHOP_DOMAIN",
18
40
  breaks: "everything derived from it is wrong at once — no public origin, the storefront rejected as untrusted, no DNS records",
@@ -1,2 +0,0 @@
1
- import type { Project } from "../project";
2
- export declare function linkCommand(project: Project, args: string[]): Promise<number>;
@@ -1,32 +0,0 @@
1
- import type { Project } from "../project";
2
- /**
3
- * `vc publish` — make the repository, give it what it needs, push.
4
- *
5
- * ── Why this exists ──────────────────────────────────────────────────────
6
- *
7
- * The goal was always "publishing to GitHub is the deploy". `vc link` sat in
8
- * front of that as a separate step, and it is worth being exact about what
9
- * it was actually for, because two of its three jobs never needed a person:
10
- *
11
- * DOTENV_PRIVATE_KEY_SECRETS vc already holds it — automatic
12
- * CLOUDFLARE_ACCOUNT_ID it is in the manifest — automatic
13
- * CLOUDFLARE_API_TOKEN exists only in Cloudflare's dashboard
14
- *
15
- * Only the third needs a human, and it cannot be removed: there is no OIDC
16
- * or workload identity federation from GitHub to the Cloudflare API, so
17
- * something has to carry a token across, and only a person can fetch one.
18
- * Everything else is ceremony that a command can do.
19
- *
20
- * So this is the one command. It creates the repository, sets all three,
21
- * and pushes — and the push is what deploys. After it, `git push` is the
22
- * whole loop forever, and `vc` is only needed to read or change a secret.
23
- *
24
- * ── The order matters ────────────────────────────────────────────────────
25
- *
26
- * The repository is created WITHOUT pushing, the credentials go on, and only
27
- * then does the push happen. `gh repo create --push` would put main there
28
- * first, which starts a deploy against a repository that has no key and no
29
- * token — a red run, an email, and a shop that did not deploy, for no
30
- * reason other than doing two things in the wrong order.
31
- */
32
- export declare function publishCommand(project: Project, args: string[]): Promise<number>;
@@ -1,220 +0,0 @@
1
- import { rmSync } from "node:fs";
2
- import { join } from "node:path";
3
- import color from "picocolors";
4
- import { writeManifest } from "../manifest";
5
- import type { Project } from "../project";
6
- import { cloudflareAccounts, getVariable, ghAuth, repoSlug, secretNames, setSecret, setVariable, variableNames, verifyCloudflareToken } from "./github";
7
- import { LOCAL_KEY_FILE, keyState, localPrivateKey, provisionKey, publicKeyFor } from "./keys";
8
- import { PRIVATE_KEY_VAR, SECRETS_FILE, committedPublicKeyInto, declaredSecretNames, initSecrets } from "./secrets";
9
-
10
- /**
11
- * `vc link` — the one manual step, done once, from the terminal.
12
- *
13
- * ── What this is for ─────────────────────────────────────────────────────
14
- *
15
- * The goal is that publishing to GitHub is the whole deploy. Everything
16
- * else in voidcommerce gets there: the app is generated from the manifest,
17
- * the secrets are committed as ciphertext, the workflow builds and deploys.
18
- * One thing cannot be automated away, and it is worth being exact about
19
- * why.
20
- *
21
- * **GitHub cannot mint a Cloudflare credential.** There is no OIDC or
22
- * workload identity federation between them — the feature request has sat
23
- * unanswered since 2025, and Cloudflare's own CI guidance still says to
24
- * store an API token in your CI provider's secrets. The Cloudflare GitHub
25
- * App does not help: it grants Cloudflare access to the repository, not the
26
- * repository access to Cloudflare. Something has to authorise creating a
27
- * database in someone's account, and only Cloudflare can issue that.
28
- *
29
- * So exactly one token is typed, once. This command takes it, checks it
30
- * against the Cloudflare API before trusting it, and puts it in GitHub's
31
- * encrypted secrets — never on disk, never in a shell profile, never in
32
- * `~/.wrangler`. After that, `git push` is the deploy, forever.
33
- *
34
- * ── What it deliberately does not do ─────────────────────────────────────
35
- *
36
- * It does not create the token. That would need a token. The dashboard is
37
- * the only place a first one can come from, so this prints exactly which
38
- * permissions to tick and waits.
39
- */
40
-
41
- const TOKEN_SECRET = "CLOUDFLARE_API_TOKEN";
42
- const ACCOUNT_VAR = "CLOUDFLARE_ACCOUNT_ID";
43
-
44
- /** The scopes the deploy actually uses, and why each one is there. */
45
- const SCOPES: Array<[string, string]> = [
46
- ["Account · Workers Scripts: Edit", "deploy the worker"],
47
- ["Account · D1: Edit", "create the database and apply migrations"],
48
- ["Account · Queues: Edit", "create the order queue"],
49
- ["Account · Workers KV Storage: Edit", "sessions and caches"],
50
- ["Account · Workers R2 Storage: Edit", "product images"],
51
- ["Account · Account Settings: Read", "confirm which account this is"],
52
- ["Zone · Workers Routes: Edit", "answer on your domain"],
53
- ["Zone · Zone: Read", "confirm the domain's zone is on this account"],
54
- ["Zone · DNS: Read", "notice a hostname that already answers, before taking it over"],
55
- ["User · User Details: Read", "wrangler asks at startup"],
56
- ];
57
-
58
- export async function linkCommand(project: Project, args: string[]): Promise<number> {
59
- const p = await import("@clack/prompts");
60
- const root = project.root;
61
- const relink = args.includes("--force");
62
-
63
- p.intro(color.bgCyan(color.black(" vc link ")));
64
-
65
- // 1. gh, logged in, scoped.
66
- const auth = await ghAuth(root);
67
- if (!auth.ok) {
68
- p.cancel(auth.reason ?? "gh is unavailable");
69
- return 1;
70
- }
71
- const slug = await repoSlug(root);
72
- if (!slug) {
73
- p.cancel(
74
- "this checkout has no GitHub repository, and everything below is stored on one.\n\n" +
75
- ` ${color.cyan("gh repo create --source=. --private --push")}\n\n` +
76
- " then run `vc link` again.",
77
- );
78
- return 1;
79
- }
80
- p.log.success(`${color.green("✓")} ${slug}, as ${auth.user ?? "you"}`);
81
-
82
- // 2. The encryption key. Generated here, stored on the repository, never on disk.
83
- const keys = await keyState(project);
84
- if (keys.inGitHub && !relink) {
85
- p.log.info(`${PRIVATE_KEY_VAR} is already set — leaving it alone (--force replaces it, which orphans ${SECRETS_FILE})`);
86
- } else if (keys.inGitHub && relink) {
87
- p.log.warn(`--force: replacing ${PRIVATE_KEY_VAR} makes every value in ${SECRETS_FILE} unreadable. Use \`vc keys --rotate\` to re-encrypt instead.`);
88
- return 1;
89
- } else {
90
- // A key parked by `vc keys --init` before this repository existed is
91
- // ADOPTED, not replaced: it may already have encrypted the whole shop.
92
- const parked = localPrivateKey(root);
93
- if (parked) {
94
- const sent = await setVariable(root, PRIVATE_KEY_VAR, parked);
95
- if (!sent.ok) {
96
- p.cancel(`GitHub refused the variable: ${sent.error ?? "unknown error"}`);
97
- return 1;
98
- }
99
- const publicKey = await publicKeyFor(parked);
100
- if (publicKey) committedPublicKeyInto(root, publicKey);
101
-
102
- // READ IT BACK before deleting the only other copy. "The write
103
- // returned success" is not the same claim as "the key can be
104
- // recovered", and it is the second one this file is about to bet
105
- // the shop on. If it cannot be read, the local copy stays.
106
- const readBack = await getVariable(root, PRIVATE_KEY_VAR);
107
- if (readBack !== parked) {
108
- p.log.warn(
109
- `${color.yellow("!")} ${PRIVATE_KEY_VAR} was written to ${slug} but could not be read back,\n` +
110
- ` so ${LOCAL_KEY_FILE} has been LEFT IN PLACE. It is currently the only copy\n` +
111
- " of the key that opens this shop — do not delete it until `vc keys --restore` works.",
112
- );
113
- } else {
114
- rmSync(join(root, LOCAL_KEY_FILE), { force: true });
115
- p.log.success(
116
- `${color.green("✓")} the key moved to ${slug} and ${LOCAL_KEY_FILE} deleted — verified readable, so \`vc keys --restore\` can bring it back`,
117
- );
118
- }
119
- } else {
120
- const made = await provisionKey(project);
121
- if (!made.ok) {
122
- p.cancel(made.reason);
123
- return 1;
124
- }
125
- committedPublicKeyInto(root, made.publicKey);
126
- p.log.success(`${color.green("✓")} ${PRIVATE_KEY_VAR} generated and stored on GitHub; public half in ${SECRETS_FILE}`);
127
- }
128
- }
129
-
130
- // 3. The Cloudflare token — the one thing that cannot be derived.
131
- const existing = await secretNames(root);
132
- if (existing.has(TOKEN_SECRET) && !relink) {
133
- p.log.info(`${TOKEN_SECRET} is already set — pass --force to replace it`);
134
- } else {
135
- p.log.step(`A Cloudflare API token, from ${color.cyan("https://dash.cloudflare.com/profile/api-tokens")} → Create Token → Custom token`);
136
- console.log(SCOPES.map(([scope, why]) => ` ${scope.padEnd(38)} ${color.dim(why)}`).join("\n"));
137
- console.log(
138
- color.dim(
139
- `\n The token Cloudflare generates for its own Workers Builds will NOT do:\n` +
140
- ` it has no D1 and no Queues permission, so it cannot create the database.\n`,
141
- ),
142
- );
143
-
144
- const token = await p.password({
145
- message: "Paste it (nothing is written to disk)",
146
- validate: (input) => (input && input.length >= 20 ? undefined : "that is too short to be a Cloudflare token"),
147
- });
148
- if (p.isCancel(token)) {
149
- p.cancel("nothing was stored.");
150
- return 1;
151
- }
152
-
153
- const spinner = p.spinner();
154
- spinner.start("asking Cloudflare whether that token is real");
155
- const verified = await verifyCloudflareToken(String(token));
156
- if (!verified.ok) {
157
- spinner.stop(`${color.red("✗")} Cloudflare rejected it: ${verified.detail}`);
158
- p.cancel("nothing was stored.");
159
- return 1;
160
- }
161
- const accounts = await cloudflareAccounts(String(token));
162
- spinner.stop(`${color.green("✓")} the token is active and sees ${accounts.length} account${accounts.length === 1 ? "" : "s"}`);
163
-
164
- let accountId = project.manifest.cloudflare?.accountId ?? "";
165
- if (accounts.length === 1) {
166
- accountId = accounts[0]!.id;
167
- p.log.info(`account "${accounts[0]!.name}"`);
168
- } else if (accounts.length > 1) {
169
- const picked = await p.select({
170
- message: "Which account is this shop in?",
171
- options: accounts.map((account) => ({ value: account.id, label: account.name, hint: account.id })),
172
- });
173
- if (p.isCancel(picked)) {
174
- p.cancel("nothing was stored.");
175
- return 1;
176
- }
177
- accountId = String(picked);
178
- }
179
-
180
- const stored = await setSecret(root, TOKEN_SECRET, String(token));
181
- if (!stored.ok) {
182
- p.cancel(`GitHub refused the secret: ${stored.error ?? "unknown error"}`);
183
- return 1;
184
- }
185
- p.log.success(`${color.green("✓")} ${TOKEN_SECRET} stored on ${slug}`);
186
-
187
- if (accountId) {
188
- // A VARIABLE, not a secret: an account id is an identifier, and a
189
- // readable one is easier to debug in a workflow log.
190
- const varred = await setVariable(root, ACCOUNT_VAR, accountId);
191
- if (varred.ok) p.log.success(`${color.green("✓")} ${ACCOUNT_VAR} set as a repository variable`);
192
- project.manifest.cloudflare = { ...project.manifest.cloudflare, accountId };
193
- await writeManifest(root, project.manifest);
194
- }
195
- }
196
-
197
- // 4. The shop's own secrets, so `vc link` leaves a repository that is
198
- // ready rather than one that is half-configured.
199
- if (declaredSecretNames(root).size === 0) {
200
- p.log.step(`writing ${SECRETS_FILE}`);
201
- await initSecrets(project);
202
- }
203
-
204
- // 5. What is left.
205
- const secrets = await secretNames(root);
206
- const vars = await variableNames(root);
207
- p.outro(
208
- [
209
- `${color.bold("Ready.")} From here, ${color.cyan("git push")} is the deploy.`,
210
- "",
211
- ` ${secrets.has(PRIVATE_KEY_VAR) ? color.green("✓") : color.red("✗")} ${PRIVATE_KEY_VAR} ${color.dim("reads the shop's secrets")}`,
212
- ` ${secrets.has(TOKEN_SECRET) ? color.green("✓") : color.red("✗")} ${TOKEN_SECRET} ${color.dim("deploys to Cloudflare")}`,
213
- ` ${vars.has(ACCOUNT_VAR) ? color.green("✓") : color.dim("·")} ${ACCOUNT_VAR} ${color.dim("which account")}`,
214
- "",
215
- ` Set the shop's own secrets with ${color.cyan("vc secrets set KEY")} — that needs no`,
216
- ` credential at all, because encryption uses the public key in the repository.`,
217
- ].join("\n"),
218
- );
219
- return 0;
220
- }
@@ -1,145 +0,0 @@
1
- import { existsSync } from "node:fs";
2
- import { join } from "node:path";
3
- import color from "picocolors";
4
- import type { Project } from "../project";
5
- import { findGh, ghAuth, repoSlug, run } from "./github";
6
- import { linkCommand } from "./link";
7
-
8
- /**
9
- * `vc publish` — make the repository, give it what it needs, push.
10
- *
11
- * ── Why this exists ──────────────────────────────────────────────────────
12
- *
13
- * The goal was always "publishing to GitHub is the deploy". `vc link` sat in
14
- * front of that as a separate step, and it is worth being exact about what
15
- * it was actually for, because two of its three jobs never needed a person:
16
- *
17
- * DOTENV_PRIVATE_KEY_SECRETS vc already holds it — automatic
18
- * CLOUDFLARE_ACCOUNT_ID it is in the manifest — automatic
19
- * CLOUDFLARE_API_TOKEN exists only in Cloudflare's dashboard
20
- *
21
- * Only the third needs a human, and it cannot be removed: there is no OIDC
22
- * or workload identity federation from GitHub to the Cloudflare API, so
23
- * something has to carry a token across, and only a person can fetch one.
24
- * Everything else is ceremony that a command can do.
25
- *
26
- * So this is the one command. It creates the repository, sets all three,
27
- * and pushes — and the push is what deploys. After it, `git push` is the
28
- * whole loop forever, and `vc` is only needed to read or change a secret.
29
- *
30
- * ── The order matters ────────────────────────────────────────────────────
31
- *
32
- * The repository is created WITHOUT pushing, the credentials go on, and only
33
- * then does the push happen. `gh repo create --push` would put main there
34
- * first, which starts a deploy against a repository that has no key and no
35
- * token — a red run, an email, and a shop that did not deploy, for no
36
- * reason other than doing two things in the wrong order.
37
- */
38
-
39
- export async function publishCommand(project: Project, args: string[]): Promise<number> {
40
- const p = await import("@clack/prompts");
41
- const root = project.root;
42
- const gh = findGh();
43
-
44
- p.intro(color.bgCyan(color.black(" vc publish ")));
45
-
46
- const auth = await ghAuth(root);
47
- if (!auth.ok || !gh) {
48
- p.cancel(auth.reason ?? "the GitHub CLI is not installed. https://cli.github.com");
49
- return 1;
50
- }
51
- if (!existsSync(join(root, ".git"))) {
52
- p.cancel(`${root} is not a git repository. \`git init\` first, and commit what you have.`);
53
- return 1;
54
- }
55
-
56
- // 1. The repository. Created without pushing: see above.
57
- let slug = await repoSlug(root);
58
- if (slug) {
59
- p.log.info(`${slug} already exists — using it`);
60
- } else {
61
- const suggested = project.manifest.shop.domain.split(".")[0] ?? "shop";
62
- const name = await p.text({
63
- message: "Repository name",
64
- initialValue: suggested,
65
- validate: (value) => (/^[A-Za-z0-9._-]+$/.test(value.trim()) ? undefined : "letters, digits, dot, dash or underscore"),
66
- });
67
- if (p.isCancel(name)) {
68
- p.cancel("nothing was created.");
69
- return 1;
70
- }
71
- // PRIVATE by default and deliberately: the encryption key becomes a
72
- // repository variable, so anyone who can read this repository can
73
- // decrypt its secrets.
74
- const visibility = await p.select({
75
- message: "Visibility",
76
- options: [
77
- { value: "--private", label: "Private", hint: "the key that opens your secrets is readable to anyone with access" },
78
- { value: "--public", label: "Public", hint: "only if this shop keeps no secrets at all" },
79
- ],
80
- initialValue: "--private",
81
- });
82
- if (p.isCancel(visibility)) {
83
- p.cancel("nothing was created.");
84
- return 1;
85
- }
86
-
87
- const spinner = p.spinner();
88
- spinner.start(`creating ${String(name)}`);
89
- const created = await run(gh, ["repo", "create", String(name).trim(), "--source=.", String(visibility)], root);
90
- if (created.code !== 0) {
91
- spinner.stop(`${color.red("✗")} could not create it`);
92
- p.cancel(created.out.trim().split("\n").slice(-2).join(" "));
93
- return 1;
94
- }
95
- slug = await repoSlug(root);
96
- spinner.stop(`${color.green("✓")} ${slug ?? String(name)} created, and set as origin`);
97
- }
98
-
99
- // 2. The credentials. Two are automatic; one is asked for.
100
- p.log.step("giving the repository what the deploy needs");
101
- const linked = await linkCommand(project, args);
102
- if (linked !== 0) {
103
- p.cancel("the repository exists but is not configured, so a push would not deploy. Fix the above and run `vc publish` again.");
104
- return 1;
105
- }
106
-
107
- // 3. Anything `vc link` wrote — the public key, a fresh .env.secrets, the
108
- // pinned account id — has to be IN the push, or the deploy reads a
109
- // repository that disagrees with the one on this machine.
110
- const dirty = (await run("git", ["status", "--porcelain"], root)).out.trim();
111
- if (dirty) {
112
- const staged = await run("git", ["add", "-A"], root);
113
- const committed = await run("git", ["commit", "-m", "vc publish: link this shop to its repository"], root);
114
- if (staged.code !== 0 || committed.code !== 0) {
115
- p.cancel(`could not commit the changes vc link made:\n${committed.out.trim().split("\n").slice(-3).join("\n")}`);
116
- return 1;
117
- }
118
- p.log.success(`${color.green("✓")} committed what linking changed`);
119
- }
120
-
121
- // 4. Push. This is the deploy.
122
- const branch = (await run("git", ["rev-parse", "--abbrev-ref", "HEAD"], root)).out.trim() || "main";
123
- const spinner = p.spinner();
124
- spinner.start(`pushing ${branch}`);
125
- const pushed = await run("git", ["push", "-u", "origin", `${branch}:main`], root);
126
- if (pushed.code !== 0) {
127
- spinner.stop(`${color.red("✗")} the push failed`);
128
- p.cancel(pushed.out.trim().split("\n").slice(-4).join("\n"));
129
- return 1;
130
- }
131
- spinner.stop(`${color.green("✓")} pushed to main`);
132
-
133
- p.outro(
134
- [
135
- `${color.bold("Published.")} The deploy is running now.`,
136
- "",
137
- ` ${color.cyan(`https://github.com/${slug}/actions`)}`,
138
- "",
139
- ` From here ${color.cyan("git push")} is the whole loop. \`vc\` is only needed to`,
140
- ` change a secret (${color.cyan("vc secrets set KEY")}) or read one back`,
141
- ` (${color.cyan("vc keys --restore")}).`,
142
- ].join("\n"),
143
- );
144
- return 0;
145
- }