@timekast/factory 1.28.1 β 2.0.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.
- package/dist/commands/backlog.js +52 -15
- package/dist/commands/doctor.js +210 -29
- package/dist/commands/env.js +146 -18
- package/dist/commands/invite-admin.js +62 -15
- package/dist/commands/provision.js +5798 -761
- package/dist/commands/status.js +12 -2
- package/dist/commands/ticket.js +4 -4
- package/dist/commands/update.js +97 -12
- package/dist/commands/vault.js +2627 -0
- package/dist/index.js +97 -33
- package/dist/lib/backlog-mapping.js +111 -18
- package/dist/lib/backlog-parse.js +1 -1
- package/dist/lib/claude-md.js +320 -0
- package/dist/lib/cloudflare-api.js +213 -37
- package/dist/lib/constants.js +70 -6
- package/dist/lib/env-file.js +45 -7
- package/dist/lib/env-wizard.js +66 -2
- package/dist/lib/initial-migration.js +3 -2
- package/dist/lib/neon-api.js +145 -10
- package/dist/lib/package-json.js +37 -16
- package/dist/lib/preflight.js +92 -38
- package/dist/lib/provision-preflight.js +158 -100
- package/dist/lib/provision-state.js +652 -20
- package/dist/lib/publish-engine.js +1 -1
- package/dist/lib/rail.js +400 -0
- package/dist/lib/railway-api.js +851 -0
- package/dist/lib/vault-adopt.js +1495 -0
- package/dist/lib/vault-api.js +1101 -0
- package/dist/lib/vault-config.js +21 -0
- package/dist/lib/vault-deploys.js +898 -0
- package/dist/lib/vercel-env.js +251 -31
- package/dist/lib/wrapper-wiring.js +160 -0
- package/dist/tui/env-wizard-flow.js +17 -3
- package/dist/tui/menu.js +4 -22
- package/dist/tui/provision-flow.js +68 -13
- package/package.json +1 -1
- package/dist/commands/secrets.js +0 -71
- package/dist/lib/env-merge.js +0 -238
package/dist/lib/constants.js
CHANGED
|
@@ -7,9 +7,6 @@
|
|
|
7
7
|
export const FACTORY_ORG = 'TimeKast';
|
|
8
8
|
/** The Factory monorepo, source of the distribution releases. */
|
|
9
9
|
export const FACTORY_REPO = 'TimeKast/TimeKast-Factory';
|
|
10
|
-
// NOTE: the org secrets gate constants (SECRETS_GATE_URL, GITHUB_DEVICE_CLIENT_ID,
|
|
11
|
-
// GITHUB_DEVICE_SCOPE, SECRETS_STATE_FILE) were REMOVED with the `secrets pull`/`sync`
|
|
12
|
-
// device-flow gate β that droplet effort is parked; the future is Infisical.
|
|
13
10
|
/** The static hub repo that hosts published proposals/mockups (`factory publish`). */
|
|
14
11
|
export const PROPOSALS_HUB_REPO = `${FACTORY_ORG}/proposals`;
|
|
15
12
|
/** HTTPS git URL of the hub (token is injected by the publish command). */
|
|
@@ -148,6 +145,66 @@ export const FACTORY_SCRIPTS = {
|
|
|
148
145
|
[UNPUBLISH_SCRIPT_NAME]: UNPUBLISH_SCRIPT_CMD,
|
|
149
146
|
[ENV_PUSH_SCRIPT_NAME]: ENV_PUSH_SCRIPT_CMD,
|
|
150
147
|
};
|
|
148
|
+
/**
|
|
149
|
+
* The local wrapper that injects the vault's environment into the kit's scripts (VAULT-003).
|
|
150
|
+
* Ships with the FULL brain (`scripts/**`); the `core` profile does not carry it.
|
|
151
|
+
*/
|
|
152
|
+
export const WITH_VAULT_SCRIPT_FILE = 'scripts/tools/with-vault.mjs';
|
|
153
|
+
/** The prefix every wrapped script value starts with. */
|
|
154
|
+
export const WITH_VAULT_SCRIPT_CMD = `node ${WITH_VAULT_SCRIPT_FILE}`;
|
|
155
|
+
/**
|
|
156
|
+
* π΄ THE single source of the kit's script values that go through the wrapper (VAULT-004). The
|
|
157
|
+
* Factory's own `package.json` carries exactly these values (a test in `package-json.test.ts`
|
|
158
|
+
* reads the real file and pins it), the wiring of `provision` (`wrapper-wiring.ts`) rewrites a
|
|
159
|
+
* derivative's scripts to them, and every older constant that names one of these scripts
|
|
160
|
+
* (`SETUP_E2E_VARS_SCRIPT_CMD`) reads it from here β nobody repeats the literal.
|
|
161
|
+
*
|
|
162
|
+
* `invite:admin` is NOT here: it reads its `--target` environment straight from the vault
|
|
163
|
+
* (VAULT-003), so its value did not change and there is nothing to wrap or to heal.
|
|
164
|
+
*/
|
|
165
|
+
const VAULT_WRAPPED_SCRIPT_SPECS = [
|
|
166
|
+
{ name: 'dev', inner: 'node scripts/tools/dev.mjs' },
|
|
167
|
+
{ name: 'build', inner: 'next build' },
|
|
168
|
+
// `analyze` runs its own `next build` (with ANALYZE on): the build needs the same env as `build`.
|
|
169
|
+
// The wrapper wraps only that first command β the bundle report after `&&` reads `.next/`, no secret.
|
|
170
|
+
{ name: 'analyze', inner: 'next build && node scripts/tools/analyze-bundle.mjs' },
|
|
171
|
+
{ name: 'start', inner: 'next start' },
|
|
172
|
+
{ name: 'test:e2e', inner: 'tsx scripts/tools/e2e-runner.ts' },
|
|
173
|
+
{ name: 'env:check', inner: 'tsx scripts/tools/env-check.ts' },
|
|
174
|
+
{ name: 'db:generate', inner: 'drizzle-kit generate' },
|
|
175
|
+
{ name: 'db:migrate', inner: 'drizzle-kit migrate' },
|
|
176
|
+
{ name: 'db:push', inner: 'drizzle-kit push' },
|
|
177
|
+
{ name: 'db:studio', inner: 'drizzle-kit studio' },
|
|
178
|
+
{ name: 'db:harden-enum', inner: 'tsx scripts/tools/harden-enum.ts' },
|
|
179
|
+
{ name: 'db:query', inner: 'tsx scripts/tools/db-query.ts' },
|
|
180
|
+
{ name: 'db:query:dev', inner: 'tsx scripts/tools/db-query.ts' },
|
|
181
|
+
{ name: 'db:query:main', inner: 'tsx scripts/tools/db-query.ts --main', vaultEnv: 'main' },
|
|
182
|
+
{
|
|
183
|
+
name: 'db:seed',
|
|
184
|
+
inner: 'tsx --require dotenv/config src/lib/db/seed.ts dotenv_config_path=.env.local',
|
|
185
|
+
},
|
|
186
|
+
{ name: 'setup:e2e', inner: 'tsx scripts/tools/setup-e2e.ts', vaultEnv: 'develop' },
|
|
187
|
+
{
|
|
188
|
+
name: 'setup:e2e:vars',
|
|
189
|
+
inner: 'tsx scripts/tools/setup-e2e.ts --publish-vars',
|
|
190
|
+
vaultEnv: 'develop',
|
|
191
|
+
},
|
|
192
|
+
{ name: 'generate:email-logo', inner: 'tsx scripts/tools/generate-email-logo.ts' },
|
|
193
|
+
];
|
|
194
|
+
/** Put the wrapper in front of `inner`, with its environment flag when it is not `local`. */
|
|
195
|
+
function wrapWithVault(inner, vaultEnv) {
|
|
196
|
+
return vaultEnv
|
|
197
|
+
? `${WITH_VAULT_SCRIPT_CMD} --vault-env=${vaultEnv} ${inner}`
|
|
198
|
+
: `${WITH_VAULT_SCRIPT_CMD} ${inner}`;
|
|
199
|
+
}
|
|
200
|
+
/** The registry, keyed by script name (see {@link VAULT_WRAPPED_SCRIPT_SPECS}). */
|
|
201
|
+
export const VAULT_WRAPPED_SCRIPTS = Object.freeze(Object.fromEntries(VAULT_WRAPPED_SCRIPT_SPECS.map((spec) => [
|
|
202
|
+
spec.name,
|
|
203
|
+
{
|
|
204
|
+
cmd: wrapWithVault(spec.inner, spec.vaultEnv),
|
|
205
|
+
unwrapped: [spec.inner, ...(spec.historic ?? [])],
|
|
206
|
+
},
|
|
207
|
+
])));
|
|
151
208
|
/**
|
|
152
209
|
* Kit-local convenience script: the readiness sweep (`pnpm preflight`, consumed
|
|
153
210
|
* by the /deploy gate and /preflight). Unlike the npx-based `factory:*` scripts
|
|
@@ -175,11 +232,15 @@ export const INVITE_ADMIN_SCRIPT_FILE = 'scripts/tools/invite-admin.ts';
|
|
|
175
232
|
* tsx-based (not npx) and FILE-GATED, same contract as `preflight`: a
|
|
176
233
|
* core/non-Factory derivative without `scripts/tools/setup-e2e.ts` never gets
|
|
177
234
|
* a broken `setup:e2e:vars`. Insert-if-missing; self-heals on the next update
|
|
178
|
-
* once the script lands.
|
|
179
|
-
*
|
|
235
|
+
* once the script lands.
|
|
236
|
+
*
|
|
237
|
+
* Runs through the vault wrapper (VAULT-004): the value comes from `VAULT_WRAPPED_SCRIPTS`,
|
|
238
|
+
* and the unwrapped value the kit shipped before is registered in `OBSOLETE_SCRIPT_CMDS` so
|
|
239
|
+
* `factory update` heals it. Both the insertion and the healing are ALSO gated on the wrapper
|
|
240
|
+
* file (`kitLocalScripts`): a repo without it never gets a script pointing at a missing file.
|
|
180
241
|
*/
|
|
181
242
|
export const SETUP_E2E_VARS_SCRIPT_NAME = 'setup:e2e:vars';
|
|
182
|
-
export const SETUP_E2E_VARS_SCRIPT_CMD =
|
|
243
|
+
export const SETUP_E2E_VARS_SCRIPT_CMD = VAULT_WRAPPED_SCRIPTS[SETUP_E2E_VARS_SCRIPT_NAME].cmd;
|
|
183
244
|
export const SETUP_E2E_VARS_SCRIPT_FILE = 'scripts/tools/setup-e2e.ts';
|
|
184
245
|
/**
|
|
185
246
|
* Kit-local Playwright browser install alias (`pnpm e2e:setup`).
|
|
@@ -405,6 +466,9 @@ export const OBSOLETE_SCRIPT_CMDS = {
|
|
|
405
466
|
// whole COMMANDS (not just the number) so healing stays exact-match: a derivative that
|
|
406
467
|
// appended a flag of its own, or picked a ceiling the kit never shipped, is left alone.
|
|
407
468
|
[VERIFY_SCRIPT_NAME]: VERIFY_OBSOLETE_CMDS,
|
|
469
|
+
// VAULT-004: the value the kit shipped before the vault wrapper. `invite:admin` has no entry:
|
|
470
|
+
// its value did not change in the release that brought the wrapper (VAULT-003).
|
|
471
|
+
[SETUP_E2E_VARS_SCRIPT_NAME]: VAULT_WRAPPED_SCRIPTS[SETUP_E2E_VARS_SCRIPT_NAME].unwrapped,
|
|
408
472
|
[DEV_NEXT_SCRIPT_NAME]: DEV_NEXT_OBSOLETE_HEAPS_MB.map((mb) => `NODE_OPTIONS='--max-old-space-size=${mb}' next dev --turbopack`),
|
|
409
473
|
};
|
|
410
474
|
/**
|
package/dist/lib/env-file.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { chmodSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
import { CLIError } from './cli-error.js';
|
|
13
|
+
import { hasVaultLink } from './provision-state.js';
|
|
13
14
|
import { generateAuthSecret } from './vercel-env.js';
|
|
14
15
|
/**
|
|
15
16
|
* Vars that provision owns and sets PER-ENVIRONMENT (or keeps local-only). The
|
|
@@ -35,6 +36,9 @@ export const PROVISION_OWNED = new Set([
|
|
|
35
36
|
// (rpID must match the env's host) or the at-rest TOTP crypto.
|
|
36
37
|
'MFA_ENCRYPTION_KEY',
|
|
37
38
|
'WEBAUTHN_RP_ID',
|
|
39
|
+
// The origins allowed to run a ceremony for that rpID (Related Origin Requests): provision
|
|
40
|
+
// sets the develop origin so ONE rpID (the production host) serves both deploys.
|
|
41
|
+
'WEBAUTHN_RELATED_ORIGINS',
|
|
38
42
|
// LOCAL-ONLY E2E credentials (like DATABASE_URL_MAIN): the E2E runner reads them
|
|
39
43
|
// from `.env.local` to spin an isolated Neon branch per run. Both local and CI hold
|
|
40
44
|
// the SAME project-scoped key (see `writeE2eLocalCreds`); they must NEVER reach
|
|
@@ -48,6 +52,29 @@ export const PROVISION_OWNED = new Set([
|
|
|
48
52
|
'BACKLOG_API_KEY',
|
|
49
53
|
'BACKLOG_PROJECT_ID',
|
|
50
54
|
]);
|
|
55
|
+
/**
|
|
56
|
+
* The LOCAL-ONLY subset of {@link PROVISION_OWNED}: keys that must NEVER land in a vault folder
|
|
57
|
+
* that syncs to a deploy (Vercel production/preview). The vault client's write guard
|
|
58
|
+
* (`vault-api.ts`) reads THIS set β the per-key allowed destinations live there; this is only the
|
|
59
|
+
* membership. MUST stay a subset of `PROVISION_OWNED` (pinned by `tests/unit/env-file.test.ts`).
|
|
60
|
+
*/
|
|
61
|
+
export const LOCAL_ONLY_KEYS = new Set([
|
|
62
|
+
'NEON_API_KEY',
|
|
63
|
+
'NEON_PROJECT_ID',
|
|
64
|
+
'DATABASE_URL_MAIN',
|
|
65
|
+
'BACKLOG_API_KEY',
|
|
66
|
+
'BACKLOG_PROJECT_ID',
|
|
67
|
+
]);
|
|
68
|
+
/**
|
|
69
|
+
* π΄ VAULT GUARD (VAULT-002) β every writer below refuses to touch `.env.local` in a repo linked
|
|
70
|
+
* to the vault (`vault` block in `.timekast/provision.json`). There the values live in the vault
|
|
71
|
+
* and each writer's caller routes them there; this check is the defense in depth that keeps a
|
|
72
|
+
* forgotten path from re-creating a file Next.js would load on top of the vault's values
|
|
73
|
+
* (`fx-secrets-vault Β§7`). Fail-closed: an unreadable state file throws (see `readVaultLink`).
|
|
74
|
+
*/
|
|
75
|
+
function linkedToVault(rootDir) {
|
|
76
|
+
return hasVaultLink(rootDir);
|
|
77
|
+
}
|
|
51
78
|
/**
|
|
52
79
|
* Replace the value of `KEY=...` in an env-file body, preserving every other line.
|
|
53
80
|
* Appends `KEY="value"` when the key is absent (so an older `.env.example` without
|
|
@@ -74,8 +101,12 @@ export function setEnvLine(body, key, value) {
|
|
|
74
101
|
* β the main branch (read by `pnpm db:query:main`). NEVER clobbers an existing
|
|
75
102
|
* `.env.local` (the dev may have edited it) unless `--force`. The `.env.example` stays
|
|
76
103
|
* the SSOT of which keys exist; the keys are appended if absent (older scaffolds).
|
|
104
|
+
*
|
|
105
|
+
* Returns `'vault'` (and writes nothing) in a repo linked to the vault β see {@link linkedToVault}.
|
|
77
106
|
*/
|
|
78
107
|
export function writeEnvLocal(rootDir, neon, opts = {}) {
|
|
108
|
+
if (linkedToVault(rootDir))
|
|
109
|
+
return 'vault';
|
|
79
110
|
const examplePath = join(rootDir, '.env.example');
|
|
80
111
|
if (!existsSync(examplePath))
|
|
81
112
|
return 'no-example';
|
|
@@ -88,7 +119,7 @@ export function writeEnvLocal(rootDir, neon, opts = {}) {
|
|
|
88
119
|
body = setEnvLine(body, 'AUTH_SECRET', generateAuthSecret());
|
|
89
120
|
// `.env.local` holds DB strings + AUTH_SECRET β owner-only (0600) from birth.
|
|
90
121
|
// The `mode: 0o600` makes the file created 0600 with no world-readable window
|
|
91
|
-
// between create and chmod β critical on
|
|
122
|
+
// between create and chmod β critical on a shared headless host
|
|
92
123
|
// where the default umask would otherwise leave one tenant's secrets briefly
|
|
93
124
|
// readable by others. `mode` only applies when the file is CREATED, so the
|
|
94
125
|
// chmod below still covers the `--force` overwrite path (file already exists).
|
|
@@ -121,6 +152,8 @@ export function writeEnvLocal(rootDir, neon, opts = {}) {
|
|
|
121
152
|
export function writeE2eLocalCreds(rootDir, projectId, apiKey) {
|
|
122
153
|
if (!projectId)
|
|
123
154
|
return false;
|
|
155
|
+
if (linkedToVault(rootDir))
|
|
156
|
+
return false;
|
|
124
157
|
const localPath = join(rootDir, '.env.local');
|
|
125
158
|
if (!existsSync(localPath))
|
|
126
159
|
return false;
|
|
@@ -133,23 +166,26 @@ export function writeE2eLocalCreds(rootDir, projectId, apiKey) {
|
|
|
133
166
|
return true;
|
|
134
167
|
}
|
|
135
168
|
/**
|
|
136
|
-
* Write the
|
|
169
|
+
* Write the project's email config into `.env.local` so local sends (`invite:admin`, magic-link,
|
|
137
170
|
* `db:seed`) actually DELIVER instead of falling back to the console. Sets `EMAIL_PROVIDER=resend`
|
|
138
|
-
* + `RESEND_API_KEY` (the
|
|
139
|
-
* + `EMAIL_FROM` (the
|
|
140
|
-
* dispatcher (`src/lib/email/index.ts`), so
|
|
171
|
+
* + `RESEND_API_KEY` (the PROJECT's own send-only key that provision minted β never the rail's
|
|
172
|
+
* team key, which only mints) + `EMAIL_FROM` (the sender on the domain that key is limited to).
|
|
173
|
+
* Non-production sends are auto-tagged `[Dev]` by the dispatcher (`src/lib/email/index.ts`), so
|
|
174
|
+
* local delivery is safe AND distinguishable.
|
|
141
175
|
*
|
|
142
176
|
* This closes the source-inconsistency bug: `invite-admin --target main` writes to the prod DB
|
|
143
177
|
* but the script always reads email config from `.env.local` β which provision otherwise never
|
|
144
178
|
* populated (it only synced Resend to Vercel), so the invite email silently fell back to console.
|
|
145
179
|
*
|
|
146
|
-
* Returns false (no-op) when
|
|
147
|
-
* (`writeEnvLocal` creates it first). Safe on an existing file: `setEnvLine` only touches these
|
|
180
|
+
* Returns false (no-op) when there is no project key, the repo lives in the vault, or
|
|
181
|
+
* `.env.local` does not exist yet (`writeEnvLocal` creates it first). Safe on an existing file: `setEnvLine` only touches these
|
|
148
182
|
* three keys, preserving the rest.
|
|
149
183
|
*/
|
|
150
184
|
export function writeEmailLocalCreds(rootDir, resendKey, emailFrom) {
|
|
151
185
|
if (!resendKey)
|
|
152
186
|
return false;
|
|
187
|
+
if (linkedToVault(rootDir))
|
|
188
|
+
return false;
|
|
153
189
|
const localPath = join(rootDir, '.env.local');
|
|
154
190
|
if (!existsSync(localPath))
|
|
155
191
|
return false;
|
|
@@ -179,6 +215,8 @@ export function writeEmailLocalCreds(rootDir, resendKey, emailFrom) {
|
|
|
179
215
|
export function writeBacklogLocalCreds(rootDir, projectId, apiKey) {
|
|
180
216
|
if (!apiKey || !projectId)
|
|
181
217
|
return false;
|
|
218
|
+
if (linkedToVault(rootDir))
|
|
219
|
+
return false;
|
|
182
220
|
const localPath = join(rootDir, '.env.local');
|
|
183
221
|
let body = '';
|
|
184
222
|
if (existsSync(localPath)) {
|
package/dist/lib/env-wizard.js
CHANGED
|
@@ -16,6 +16,7 @@ import { join } from 'node:path';
|
|
|
16
16
|
import { CLIError } from './cli-error.js';
|
|
17
17
|
import { PROVISION_OWNED, setEnvLine } from './env-file.js';
|
|
18
18
|
import PUBLIC_ENV_KEYS from './public-env-keys.json' with { type: 'json' };
|
|
19
|
+
import { VAULT_ENV_DEVELOP, VAULT_ENV_LOCAL, VAULT_ENV_MAIN, VAULT_ROOT_PATH, } from './vault-api.js';
|
|
19
20
|
/**
|
|
20
21
|
* The real conditionals `.env.example` can't express. Keyed by the DEPENDENT var
|
|
21
22
|
* β the driver answer that reveals it. Kept deliberately small β adding a new
|
|
@@ -130,7 +131,8 @@ export function questionKind(key, defaultValue) {
|
|
|
130
131
|
* - the GATED dependents (email creds, OAuth creds), active or commented.
|
|
131
132
|
* Excludes provision-owned vars and advanced commented vars (rate-limit, VAPID,
|
|
132
133
|
* Upstash, Sentry, NextAuth aliases) that aren't gated β those stay manual +
|
|
133
|
-
* `pnpm env:push
|
|
134
|
+
* `pnpm env:push` (only without a vault: in a repo with a `vault` block they are set in the
|
|
135
|
+
* vault, and `env:push` does not write).
|
|
134
136
|
*/
|
|
135
137
|
export function buildQuestionPlan(entries) {
|
|
136
138
|
const questions = [];
|
|
@@ -403,7 +405,13 @@ export function generateVapidKeys() {
|
|
|
403
405
|
* these same rows, so the agent-facing output obeys the identical rule.
|
|
404
406
|
*/
|
|
405
407
|
export function buildEnvReview(questions, body) {
|
|
406
|
-
|
|
408
|
+
return buildEnvReviewFromEnv(questions, parseEnvBody(body));
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* {@link buildEnvReview} over an already-resolved `{ KEY: value }` map β the vault path
|
|
412
|
+
* (VAULT-002) resolves the `local` environment instead of reading `.env.local`. Same masking rule.
|
|
413
|
+
*/
|
|
414
|
+
export function buildEnvReviewFromEnv(questions, env) {
|
|
407
415
|
return questions.map((q) => {
|
|
408
416
|
const value = env[q.key] ?? '';
|
|
409
417
|
const status = value ? 'set' : 'empty';
|
|
@@ -416,3 +424,59 @@ export function buildEnvReview(questions, body) {
|
|
|
416
424
|
};
|
|
417
425
|
});
|
|
418
426
|
}
|
|
427
|
+
const WIZARD_MAIN = { env: VAULT_ENV_MAIN, path: VAULT_ROOT_PATH };
|
|
428
|
+
const WIZARD_DEVELOP = { env: VAULT_ENV_DEVELOP, path: VAULT_ROOT_PATH };
|
|
429
|
+
const WIZARD_LOCAL = { env: VAULT_ENV_LOCAL, path: VAULT_ROOT_PATH };
|
|
430
|
+
/**
|
|
431
|
+
* Write `entries` once in `main:/` (with `mode`) and reference each from `develop:/` and `local`.
|
|
432
|
+
* Returns the key names written. Never prints (the caller narrates, by name).
|
|
433
|
+
*/
|
|
434
|
+
export async function writeSharedToVault(target, entries, mode) {
|
|
435
|
+
const keys = Object.keys(entries);
|
|
436
|
+
if (keys.length === 0)
|
|
437
|
+
return [];
|
|
438
|
+
await target.client.writeSecrets(target.projectId, WIZARD_MAIN, entries, mode);
|
|
439
|
+
for (const key of keys) {
|
|
440
|
+
for (const location of [WIZARD_DEVELOP, WIZARD_LOCAL]) {
|
|
441
|
+
await target.client.writeReference(target.projectId, location, key, { ...WIZARD_MAIN, key }, 'upsert');
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return keys;
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* The vault counterpart of {@link applyWizardAnswers}: the answers go to the vault (one copy in
|
|
448
|
+
* `main:/`, referenced from `develop:/` and `local`), never to a file. Provision-owned keys are
|
|
449
|
+
* skipped exactly as on the file path; EMPTY answers are not stored (the vault keeps answered
|
|
450
|
+
* values) and come back in `empty` so the caller can name them.
|
|
451
|
+
*/
|
|
452
|
+
export async function applyWizardAnswersToVault(target, answers) {
|
|
453
|
+
const entries = {};
|
|
454
|
+
const empty = [];
|
|
455
|
+
for (const [key, value] of Object.entries(answers)) {
|
|
456
|
+
if (PROVISION_OWNED.has(key))
|
|
457
|
+
continue; // provision owns these per-env
|
|
458
|
+
if (value === '') {
|
|
459
|
+
empty.push(key);
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
entries[key] = value;
|
|
463
|
+
}
|
|
464
|
+
const written = await writeSharedToVault(target, entries, 'upsert');
|
|
465
|
+
return { written, empty };
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* The `local` environment as the app would see it: its own values, the `develop:/ci` import and
|
|
469
|
+
* the references resolved (own values win over imported ones β ADR-005, check (c)). For the
|
|
470
|
+
* completeness warnings and the masked review; values never leave this process unmasked.
|
|
471
|
+
*/
|
|
472
|
+
export async function readVaultLocalEnv(target) {
|
|
473
|
+
const secrets = await target.client.readSecrets(target.projectId, WIZARD_LOCAL, {
|
|
474
|
+
expandReferences: true,
|
|
475
|
+
includeImports: true,
|
|
476
|
+
});
|
|
477
|
+
return Object.fromEntries(secrets);
|
|
478
|
+
}
|
|
479
|
+
/** Read `main:/` (unexpanded) β e.g. to seed `VAPID_SUBJECT` from `EMAIL_FROM`. */
|
|
480
|
+
export async function readVaultMain(target) {
|
|
481
|
+
return Object.fromEntries(await target.client.readSecrets(target.projectId, WIZARD_MAIN));
|
|
482
|
+
}
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
* signal (GAP-001, fixed by `project/planning/decisions/ADR-003-factory-signal.md`).
|
|
4
4
|
*
|
|
5
5
|
* WHY THIS MODULE EXISTS. `factory provision` never ran `db:generate`, so a fresh
|
|
6
|
-
* derivative was born with a schema and no migrations while its
|
|
7
|
-
* (`pnpm build && pnpm db:migrate`)
|
|
6
|
+
* derivative was born with a schema and no migrations while its deploy migration β
|
|
7
|
+
* `vercel-build` (`pnpm build && pnpm db:migrate`) on Vercel, the service's pre-deploy command
|
|
8
|
+
* (`pnpm db:migrate`) on Railway β migrated an empty database on the first deploy.
|
|
8
9
|
* The provision step that closes that gap has to decide, from disk alone, whether
|
|
9
10
|
* generating and committing a `0000` is the right thing to do β and the cost of a
|
|
10
11
|
* wrong "yes" is asymmetric.
|
package/dist/lib/neon-api.js
CHANGED
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
* Every call surfaces a typed {@link NeonApiError} on a non-2xx response that
|
|
13
13
|
* carries the HTTP status, so the caller (`provision.ts`) can map the Neon edge
|
|
14
14
|
* cases the issue calls out (Β§8) to actionable, es-MX `CLIError` messages:
|
|
15
|
-
* - 401 β `NEON_API_KEY` invalid/rotated β
|
|
15
|
+
* - 401 β `NEON_API_KEY` invalid/rotated β check the vault session, then a rotation
|
|
16
|
+
* in `rail-timekast` (`fx-secrets-vault Β§9`)
|
|
16
17
|
* - 402 / 429 β quota / billing β billing message
|
|
17
18
|
* - 409 β project name already exists β suggest `--force` or another name
|
|
18
19
|
*
|
|
@@ -134,7 +135,7 @@ export async function listNeonOrgs(apiKey, deps = {}) {
|
|
|
134
135
|
* 3. Neither: a key that sees exactly ONE org uses it; a key that sees several
|
|
135
136
|
* THROWS, naming them. Fail-closed on purpose β guessing here would create a
|
|
136
137
|
* client's database in the wrong organization, and there is no prompt to fall
|
|
137
|
-
* back on (headless
|
|
138
|
+
* back on (headless runs have nobody to answer it).
|
|
138
139
|
*
|
|
139
140
|
* `selector` matches the `org-...` id verbatim, or the name/handle
|
|
140
141
|
* case-insensitively, so both `--neon-org TimeKast` and `--neon-org org-abc123`
|
|
@@ -148,7 +149,7 @@ export async function listNeonOrgs(apiKey, deps = {}) {
|
|
|
148
149
|
export async function resolveNeonOrg(apiKey, selector, deps = {}) {
|
|
149
150
|
const orgs = await listNeonOrgs(apiKey, deps);
|
|
150
151
|
if (orgs.length === 0) {
|
|
151
|
-
throw new Error('La `NEON_API_KEY` del
|
|
152
|
+
throw new Error('La `NEON_API_KEY` del rail no ve ninguna organizaciΓ³n de Neon.\n' +
|
|
152
153
|
' Debe ser la key PERSONAL de la cuenta de servicio, y esa cuenta tiene que ser ' +
|
|
153
154
|
'Admin de la organizaciΓ³n (solo un Admin puede acuΓ±ar API keys project-scoped).');
|
|
154
155
|
}
|
|
@@ -167,8 +168,8 @@ export async function resolveNeonOrg(apiKey, selector, deps = {}) {
|
|
|
167
168
|
return orgs[0];
|
|
168
169
|
throw new Error(`La key ve ${orgs.length} organizaciones de Neon y no dijiste en cuΓ‘l crear el proyecto.\n` +
|
|
169
170
|
` Visibles: ${orgs.map((o) => `${o.name} (${o.id})`).join(', ')}.\n` +
|
|
170
|
-
' Elige una con `--neon-org "<nombre>"`, o
|
|
171
|
-
'`NEON_ORG
|
|
171
|
+
' Elige una con `--neon-org "<nombre>"`, o pide a un admin de la bΓ³veda que fije el ' +
|
|
172
|
+
'default cargando `NEON_ORG` (el nombre de la organizaciΓ³n) en `rail-timekast`.');
|
|
172
173
|
}
|
|
173
174
|
/**
|
|
174
175
|
* The organization that owns a project, asked BY ID β `GET /projects/{id}`.
|
|
@@ -482,7 +483,7 @@ export async function createProjectApiKey(apiKey, orgId, projectId, keyName, dep
|
|
|
482
483
|
// The value can't be reused, so delete the stale key(s) by name and re-mint β a
|
|
483
484
|
// resume must converge, not 409. (Only the secret-pending path re-enters here;
|
|
484
485
|
// the happy path sets apiKeySecretDone and short-circuits before minting again.)
|
|
485
|
-
if (response.status === 409) {
|
|
486
|
+
if (response.status === 409 && deps.onConflict !== 'fail') {
|
|
486
487
|
await deleteNeonApiKeysByName(apiKey, orgId, keyName, { fetchImpl });
|
|
487
488
|
response = await mint();
|
|
488
489
|
}
|
|
@@ -495,15 +496,147 @@ export async function createProjectApiKey(apiKey, orgId, projectId, keyName, dep
|
|
|
495
496
|
}
|
|
496
497
|
return data.key;
|
|
497
498
|
}
|
|
499
|
+
/**
|
|
500
|
+
* The suffix of a ROTATED key name: a compact UTC timestamp `YYYYMMDDHHmmss`. Neon rejects a
|
|
501
|
+
* repeated `key_name` in the org with `409 already exists`, and a rotation mints the new key while
|
|
502
|
+
* the old one is still alive β so each rotation gets its own name, `<base>-<suffix>`.
|
|
503
|
+
*/
|
|
504
|
+
export const NEON_ROTATION_SUFFIX_RE = /^\d{14}$/;
|
|
505
|
+
/** `<base>-YYYYMMDDHHmmss` for the rotation minted at `at` (UTC). */
|
|
506
|
+
export function rotatedNeonKeyName(base, at) {
|
|
507
|
+
return `${base}-${at.toISOString().replace(/\D/g, '').slice(0, 14)}`;
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Whether `name` belongs to the key FAMILY of `base`: the base name itself, or `<base>-<suffix>`
|
|
511
|
+
* with the rotation suffix EXACTLY ({@link NEON_ROTATION_SUFFIX_RE}). A bare `startsWith` would
|
|
512
|
+
* catch another project's keys β the base of a project called `foo-e2e-x` is `foo-e2e-x-e2e`,
|
|
513
|
+
* which starts with `foo-e2e-` β so the rest after the separator must be the timestamp and
|
|
514
|
+
* nothing else.
|
|
515
|
+
*/
|
|
516
|
+
export function isNeonKeyFamily(name, base) {
|
|
517
|
+
if (name === base)
|
|
518
|
+
return true;
|
|
519
|
+
if (!name.startsWith(`${base}-`))
|
|
520
|
+
return false;
|
|
521
|
+
return NEON_ROTATION_SUFFIX_RE.test(name.slice(base.length + 1));
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* The CURRENT key of a family β the newest one β which a settle must never revoke. By `createdAt`
|
|
525
|
+
* when EVERY key of the family carries a parseable one; otherwise by the rotation suffix of the
|
|
526
|
+
* name (`<base>-YYYYMMDDHHmmss`), where the bare base name counts as the oldest. Ties break by id.
|
|
527
|
+
* `null` when the family is empty.
|
|
528
|
+
*/
|
|
529
|
+
export function newestNeonFamilyKey(keys, base) {
|
|
530
|
+
const family = keys.filter((key) => isNeonKeyFamily(key.name, base));
|
|
531
|
+
if (family.length === 0)
|
|
532
|
+
return null;
|
|
533
|
+
const byDate = family.every((key) => key.createdAt && !Number.isNaN(Date.parse(key.createdAt)));
|
|
534
|
+
const rank = (key) => byDate
|
|
535
|
+
? String(Date.parse(key.createdAt)).padStart(16, '0')
|
|
536
|
+
: key.name === base
|
|
537
|
+
? ''
|
|
538
|
+
: key.name.slice(base.length + 1);
|
|
539
|
+
return family.reduce((best, key) => {
|
|
540
|
+
const a = rank(key);
|
|
541
|
+
const b = rank(best);
|
|
542
|
+
return a > b || (a === b && key.id > best.id) ? key : best;
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* The org API keys could not be LISTED (non-2xx, network, unexpected body). Distinct from an empty
|
|
547
|
+
* list on purpose: "I could not list" must never read as "there are none" β a rotation that took
|
|
548
|
+
* the failure for an empty list would mint a new key and leave the old one alive and unrecorded.
|
|
549
|
+
*/
|
|
550
|
+
export class NeonApiKeyListError extends Error {
|
|
551
|
+
/** The HTTP status when Neon answered; `null` for a network failure or an unreadable body. */
|
|
552
|
+
status;
|
|
553
|
+
constructor(orgId, status, detail) {
|
|
554
|
+
super(`No pude listar las API keys de la organizaciΓ³n de Neon \`${orgId}\` ` +
|
|
555
|
+
`(${status === null ? detail : `Neon respondiΓ³ ${status}`}).`);
|
|
556
|
+
this.name = 'NeonApiKeyListError';
|
|
557
|
+
this.status = status;
|
|
558
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* List the org's API keys WITH their id β `GET /organizations/{org_id}/api_keys`. Read-only. The
|
|
563
|
+
* `--remint` rotation reads this BEFORE minting, so the keys it will revoke are known by id and
|
|
564
|
+
* the key it is about to mint (same name) is never among them.
|
|
565
|
+
*
|
|
566
|
+
* @returns every key that carries both an id and a name; `[]` when the org has none.
|
|
567
|
+
* @throws {NeonApiKeyListError} on a non-2xx answer, a network failure or a body of another shape.
|
|
568
|
+
*/
|
|
569
|
+
export async function listNeonApiKeys(apiKey, orgId, deps = {}) {
|
|
570
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
571
|
+
let response;
|
|
572
|
+
try {
|
|
573
|
+
response = await fetchImpl(`${NEON_API_BASE}/organizations/${encodeURIComponent(orgId)}/api_keys`, {
|
|
574
|
+
method: 'GET',
|
|
575
|
+
headers: neonHeaders(apiKey),
|
|
576
|
+
signal: AbortSignal.timeout(NEON_API_TIMEOUT_MS),
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
catch {
|
|
580
|
+
throw new NeonApiKeyListError(orgId, null, 'sin respuesta de Neon');
|
|
581
|
+
}
|
|
582
|
+
if (!response.ok)
|
|
583
|
+
throw new NeonApiKeyListError(orgId, response.status, '');
|
|
584
|
+
let data;
|
|
585
|
+
try {
|
|
586
|
+
data = await response.json();
|
|
587
|
+
}
|
|
588
|
+
catch {
|
|
589
|
+
throw new NeonApiKeyListError(orgId, null, 'respuesta ilegible');
|
|
590
|
+
}
|
|
591
|
+
// Neon returns a bare array; a `{ api_keys: [...] }` wrapper is tolerated too.
|
|
592
|
+
const list = Array.isArray(data)
|
|
593
|
+
? data
|
|
594
|
+
: data !== null &&
|
|
595
|
+
typeof data === 'object' &&
|
|
596
|
+
Array.isArray(data.api_keys)
|
|
597
|
+
? data.api_keys
|
|
598
|
+
: null;
|
|
599
|
+
if (list === null)
|
|
600
|
+
throw new NeonApiKeyListError(orgId, null, 'respuesta con otra forma');
|
|
601
|
+
return list
|
|
602
|
+
.filter((k) => k !== null && k?.id != null && typeof k?.name === 'string')
|
|
603
|
+
.map((k) => ({
|
|
604
|
+
id: String(k.id),
|
|
605
|
+
name: String(k.name),
|
|
606
|
+
...(typeof k.created_at === 'string' ? { createdAt: k.created_at } : {}),
|
|
607
|
+
}));
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Revoke ONE org API key by id β `DELETE /organizations/{org_id}/api_keys/{id}`. A 404 is "it no
|
|
611
|
+
* longer existed" and counts as revoked. Never throws: the answer is whether Neon CONFIRMED it, so
|
|
612
|
+
* a caller keeps an unconfirmed id pending instead of forgetting it.
|
|
613
|
+
*/
|
|
614
|
+
export async function revokeNeonApiKey(apiKey, orgId, keyId, deps = {}) {
|
|
615
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
616
|
+
try {
|
|
617
|
+
const response = await fetchImpl(`${NEON_API_BASE}/organizations/${encodeURIComponent(orgId)}/api_keys/${encodeURIComponent(keyId)}`, {
|
|
618
|
+
method: 'DELETE',
|
|
619
|
+
headers: neonHeaders(apiKey),
|
|
620
|
+
signal: AbortSignal.timeout(NEON_API_TIMEOUT_MS),
|
|
621
|
+
});
|
|
622
|
+
return response.ok || response.status === 404;
|
|
623
|
+
}
|
|
624
|
+
catch {
|
|
625
|
+
return false;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
498
628
|
/**
|
|
499
629
|
* Best-effort delete of every ORG API key whose name matches `keyName`
|
|
500
|
-
* (`GET /organizations/{org_id}/api_keys` β `DELETE .../api_keys/{id}`).
|
|
630
|
+
* (`GET /organizations/{org_id}/api_keys` β `DELETE .../api_keys/{id}`). Two
|
|
501
631
|
* callers:
|
|
502
|
-
* - the idempotent re-mint (clear a stale key
|
|
503
|
-
*
|
|
632
|
+
* - the idempotent re-mint of a NON-rotation run (clear a stale key that was never
|
|
633
|
+
* delivered before re-minting on resume β see {@link createProjectApiKey}), and
|
|
504
634
|
* - the ephemeral `--destroy` nuke (delete the project-scoped key it minted so
|
|
505
635
|
* the org does not accumulate dead keys across provisions/tests).
|
|
506
636
|
*
|
|
637
|
+
* `--remint` does NOT use it: a rotation lists ids first ({@link listNeonApiKeys}) and revokes
|
|
638
|
+
* them by id ({@link revokeNeonApiKey}) only once the new key was delivered.
|
|
639
|
+
*
|
|
507
640
|
* Org-scoped for the same reason the mint is (see {@link createProjectApiKey}):
|
|
508
641
|
* the keys provision issues are owned by the org, so the personal `/api_keys`
|
|
509
642
|
* listing never saw them β and with a service-account key it 404s outright.
|
|
@@ -528,7 +661,9 @@ export async function deleteNeonApiKeysByName(apiKey, orgId, keyName, deps = {})
|
|
|
528
661
|
// Neon returns a bare array; tolerate a `{ api_keys: [...] }` wrapper too.
|
|
529
662
|
const list = Array.isArray(data) ? data : (data?.api_keys ?? []);
|
|
530
663
|
const ids = list
|
|
531
|
-
.filter((k) => k?.
|
|
664
|
+
.filter((k) => k?.id != null &&
|
|
665
|
+
typeof k?.name === 'string' &&
|
|
666
|
+
(deps.family ? isNeonKeyFamily(k.name, keyName) : k.name === keyName))
|
|
532
667
|
.map((k) => String(k.id));
|
|
533
668
|
for (const id of ids) {
|
|
534
669
|
const del = await fetchImpl(`${keysBase}/${encodeURIComponent(id)}`, {
|
package/dist/lib/package-json.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import { CLIError } from './cli-error.js';
|
|
11
|
-
import { DEV_NEXT_GATE_FILE, DEV_NEXT_SCRIPT_CMD, DEV_NEXT_SCRIPT_NAME, E2E_SETUP_SCRIPT_CMD, E2E_SETUP_SCRIPT_FILE, E2E_SETUP_SCRIPT_NAME, EVIDENCE_VISUAL_SCRIPT_CMD, EVIDENCE_VISUAL_SCRIPT_FILE, EVIDENCE_VISUAL_SCRIPT_NAME, FACTORY_SCRIPTS, GENERATE_REFERENCE_CMD, GENERATE_REFERENCE_GATE_FILE, GENERATE_REFERENCE_NAME, GENERATOR_SCRIPTS, INVITE_ADMIN_SCRIPT_CMD, INVITE_ADMIN_SCRIPT_FILE, INVITE_ADMIN_SCRIPT_NAME, OBSOLETE_SCRIPT_CMDS, VERIFY_QUICK_SCRIPT_CMD, VERIFY_QUICK_SCRIPT_NAME, VERIFY_SCRIPT_CMD, VERIFY_SCRIPT_FILE, VERIFY_SCRIPT_NAME, PORTS_E2E_COMMENT, PORTS_E2E_COMMENT_KEY, PORTS_E2E_KEY, PORTS_KEY, PREFLIGHT_SCRIPT_CMD, PREFLIGHT_SCRIPT_FILE, PREFLIGHT_SCRIPT_NAME, PROFILES, RETIRED_SCRIPT_CMDS, SETUP_E2E_VARS_SCRIPT_CMD, SETUP_E2E_VARS_SCRIPT_FILE, SETUP_E2E_VARS_SCRIPT_NAME, SHIPPED_E2E_PORT, UPDATE_SCRIPT_NAME, VERCEL_BUILD_OBSOLETE_CMDS, VERCEL_BUILD_SCRIPT_CMD, VERCEL_BUILD_SCRIPT_NAME, } from './constants.js';
|
|
11
|
+
import { DEV_NEXT_GATE_FILE, DEV_NEXT_SCRIPT_CMD, DEV_NEXT_SCRIPT_NAME, E2E_SETUP_SCRIPT_CMD, E2E_SETUP_SCRIPT_FILE, E2E_SETUP_SCRIPT_NAME, EVIDENCE_VISUAL_SCRIPT_CMD, EVIDENCE_VISUAL_SCRIPT_FILE, EVIDENCE_VISUAL_SCRIPT_NAME, FACTORY_SCRIPTS, GENERATE_REFERENCE_CMD, GENERATE_REFERENCE_GATE_FILE, GENERATE_REFERENCE_NAME, GENERATOR_SCRIPTS, INVITE_ADMIN_SCRIPT_CMD, INVITE_ADMIN_SCRIPT_FILE, INVITE_ADMIN_SCRIPT_NAME, OBSOLETE_SCRIPT_CMDS, VERIFY_QUICK_SCRIPT_CMD, VERIFY_QUICK_SCRIPT_NAME, VERIFY_SCRIPT_CMD, VERIFY_SCRIPT_FILE, VERIFY_SCRIPT_NAME, PORTS_E2E_COMMENT, PORTS_E2E_COMMENT_KEY, PORTS_E2E_KEY, PORTS_KEY, PREFLIGHT_SCRIPT_CMD, PREFLIGHT_SCRIPT_FILE, PREFLIGHT_SCRIPT_NAME, PROFILES, RETIRED_SCRIPT_CMDS, SETUP_E2E_VARS_SCRIPT_CMD, SETUP_E2E_VARS_SCRIPT_FILE, SETUP_E2E_VARS_SCRIPT_NAME, SHIPPED_E2E_PORT, UPDATE_SCRIPT_NAME, VERCEL_BUILD_OBSOLETE_CMDS, VERCEL_BUILD_SCRIPT_CMD, VERCEL_BUILD_SCRIPT_NAME, WITH_VAULT_SCRIPT_FILE, } from './constants.js';
|
|
12
12
|
/**
|
|
13
13
|
* Auto-detect the profile to install into an existing repo from its
|
|
14
14
|
* `package.json`: a `factoryVersion` field means the repo was born from the
|
|
@@ -35,7 +35,7 @@ export function detectProfile(rootDir) {
|
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
/** Detect the indentation used by an existing JSON document (defaults to 2 spaces). */
|
|
38
|
-
function detectIndent(raw) {
|
|
38
|
+
export function detectIndent(raw) {
|
|
39
39
|
const match = raw.match(/^(\s+)"/m);
|
|
40
40
|
if (!match)
|
|
41
41
|
return 2;
|
|
@@ -57,6 +57,22 @@ export function parsePackageJson(raw) {
|
|
|
57
57
|
throw new CLIError('El `package.json` desempacado no es JSON vΓ‘lido; no se puede continuar de forma segura.');
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* THE one comparison every heal of a kit-owned script uses (`ensureFactoryScripts`,
|
|
62
|
+
* `healVercelBuildOrder`, the vault wiring of `wrapper-wiring.ts`): exact match, byte for byte,
|
|
63
|
+
* against the current command and the registered values the kit shipped. A variant nobody
|
|
64
|
+
* registered β a flag appended, different spacing, a step chained in front β is `own`: erring
|
|
65
|
+
* toward "leave it alone" is the safe side of this mistake.
|
|
66
|
+
*/
|
|
67
|
+
export function classifyKitScript(existing, current, shipped) {
|
|
68
|
+
if (existing === undefined)
|
|
69
|
+
return 'absent';
|
|
70
|
+
if (existing === current)
|
|
71
|
+
return 'current';
|
|
72
|
+
if (shipped.includes(existing))
|
|
73
|
+
return 'shipped';
|
|
74
|
+
return 'own';
|
|
75
|
+
}
|
|
60
76
|
/**
|
|
61
77
|
* Pure core of the script insertion: mutate `pkg.scripts` in place to ensure ALL
|
|
62
78
|
* `factory:*` convenience scripts exist (`update` / `doctor` / `status`), adding
|
|
@@ -79,18 +95,19 @@ function ensureFactoryScripts(pkg, extraScripts = {}) {
|
|
|
79
95
|
for (const [name, cmd] of Object.entries({ ...FACTORY_SCRIPTS, ...extraScripts })) {
|
|
80
96
|
const existing = scripts[name];
|
|
81
97
|
let result;
|
|
82
|
-
|
|
98
|
+
// The value is a KNOWN-OBSOLETE kit command (e.g. the old `node β¦mjs` that the .ts
|
|
99
|
+
// migration retired) β heal it to the current command. A genuinely dev-divergent value
|
|
100
|
+
// is a `conflict` and is left intact.
|
|
101
|
+
const kind = classifyKitScript(existing, cmd, OBSOLETE_SCRIPT_CMDS[name] ?? []);
|
|
102
|
+
if (kind === 'absent') {
|
|
83
103
|
scripts[name] = cmd;
|
|
84
104
|
changed = true;
|
|
85
105
|
result = { action: 'added' };
|
|
86
106
|
}
|
|
87
|
-
else if (
|
|
107
|
+
else if (kind === 'current') {
|
|
88
108
|
result = { action: 'already-correct' };
|
|
89
109
|
}
|
|
90
|
-
else if (
|
|
91
|
-
// The value is a KNOWN-OBSOLETE kit command (e.g. the old `node β¦mjs` that
|
|
92
|
-
// the .ts migration retired) β heal it to the current command. A genuinely
|
|
93
|
-
// dev-divergent value falls through to `conflict` and is left intact.
|
|
110
|
+
else if (kind === 'shipped') {
|
|
94
111
|
scripts[name] = cmd;
|
|
95
112
|
changed = true;
|
|
96
113
|
result = { action: 'healed' };
|
|
@@ -112,7 +129,8 @@ function ensureFactoryScripts(pkg, extraScripts = {}) {
|
|
|
112
129
|
*
|
|
113
130
|
* - `preflight` β when its sweep script ships (full brain).
|
|
114
131
|
* - `setup:e2e:vars` β publishes the CI E2E auth env vars (Fase 1 of the
|
|
115
|
-
* `e2e-ci-auth-env` retrofit), when `scripts/tools/setup-e2e.ts` ships (full brain)
|
|
132
|
+
* `e2e-ci-auth-env` retrofit), when `scripts/tools/setup-e2e.ts` ships (full brain) AND the
|
|
133
|
+
* vault wrapper `scripts/tools/with-vault.mjs` does too β its value runs through it (VAULT-004).
|
|
116
134
|
* - `evidence:visual` β the visual-evidence harness (`fx-visual-evidence`), gated on the
|
|
117
135
|
* harness itself (`scripts/tools/visual-evidence/`) rather than on the runner: it is what
|
|
118
136
|
* this alias needs, and a `core` derivative receives neither. Forwards to `test:e2e`, so the
|
|
@@ -152,7 +170,10 @@ export function kitLocalScripts(rootDir) {
|
|
|
152
170
|
scripts[PREFLIGHT_SCRIPT_NAME] = PREFLIGHT_SCRIPT_CMD;
|
|
153
171
|
if (has(INVITE_ADMIN_SCRIPT_FILE))
|
|
154
172
|
scripts[INVITE_ADMIN_SCRIPT_NAME] = INVITE_ADMIN_SCRIPT_CMD;
|
|
155
|
-
|
|
173
|
+
// Its value runs through the vault wrapper, so it is ALSO gated on the wrapper: without it
|
|
174
|
+
// the alias is neither inserted nor healed (a script pointing at a missing file is worse than
|
|
175
|
+
// an old one that still runs).
|
|
176
|
+
if (has(SETUP_E2E_VARS_SCRIPT_FILE) && has(WITH_VAULT_SCRIPT_FILE))
|
|
156
177
|
scripts[SETUP_E2E_VARS_SCRIPT_NAME] = SETUP_E2E_VARS_SCRIPT_CMD;
|
|
157
178
|
if (has(E2E_SETUP_SCRIPT_FILE))
|
|
158
179
|
scripts[E2E_SETUP_SCRIPT_NAME] = E2E_SETUP_SCRIPT_CMD;
|
|
@@ -401,15 +422,15 @@ export function healVercelBuildOrder(pkgPath) {
|
|
|
401
422
|
const scripts = pkg.scripts;
|
|
402
423
|
if (typeof scripts !== 'object' || scripts === null || Array.isArray(scripts))
|
|
403
424
|
return 'absent';
|
|
404
|
-
const current = scripts[VERCEL_BUILD_SCRIPT_NAME];
|
|
405
|
-
if (current === undefined)
|
|
406
|
-
return 'absent';
|
|
407
|
-
if (current === VERCEL_BUILD_SCRIPT_CMD)
|
|
408
|
-
return 'already-correct';
|
|
409
425
|
// Exact match against what the kit actually shipped, byte for byte. A dev who appended a flag
|
|
410
426
|
// β or wrote the same two halves with different spacing β keeps their command through every
|
|
411
427
|
// future update: an unlisted variant erring toward "leave it alone" is the safe side here.
|
|
412
|
-
|
|
428
|
+
const kind = classifyKitScript(scripts[VERCEL_BUILD_SCRIPT_NAME], VERCEL_BUILD_SCRIPT_CMD, VERCEL_BUILD_OBSOLETE_CMDS);
|
|
429
|
+
if (kind === 'absent')
|
|
430
|
+
return 'absent';
|
|
431
|
+
if (kind === 'current')
|
|
432
|
+
return 'already-correct';
|
|
433
|
+
if (kind === 'own')
|
|
413
434
|
return 'preserved';
|
|
414
435
|
scripts[VERCEL_BUILD_SCRIPT_NAME] = VERCEL_BUILD_SCRIPT_CMD;
|
|
415
436
|
writeFileSync(pkgPath, `${JSON.stringify(pkg, null, indent)}\n`, 'utf8');
|