@starci/hfs 4.7.10 → 4.7.11
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/CHANGELOG.md +3 -0
- package/db/local-migrate.mjs +19 -6
- package/db/migrate.mjs +74 -17
- package/package.json +1 -1
- package/release/gate.mjs +15 -5
- package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
- package/runtime/modules/schemas/application-stacks.schema.yaml +2 -0
- package/runtime/packages/cli/src/catalog.generated.mjs +4470 -2987
- package/runtime/packages/cli/src/validate-args.mjs +12 -1
- package/stack/cli.mjs +14 -3
- package/stack/docker-transport.mjs +3 -1
- package/stack/remote.mjs +8 -2
- package/stack/target-binding.mjs +125 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 4.7.11 - 2026-10-19
|
|
4
|
+
- Changed: app release and Sonar adapters use native target binding, with local database migration and Docker transport preserving the declared stack target.
|
|
5
|
+
|
|
3
6
|
## 4.7.10 - 2026-10-19
|
|
4
7
|
- Changed: bundled canon pins follow the regenerated Stylelint vocabulary and the matching lint canon cohort; HFS command behavior is unchanged.
|
|
5
8
|
|
package/db/local-migrate.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { databasePush } from '../runtime/scripts/api/supabase/database-push.mjs'
|
|
|
9
9
|
import { isMigrationName, migrationStamp } from '../runtime/scripts/hfs/rules/database-migrations.mjs';
|
|
10
10
|
import { CONFIG_FILE, MIGRATIONS_DIR } from '../runtime/scripts/hfs/rules/database-constants.mjs';
|
|
11
11
|
import { failure, refusal } from '../stack/errors.mjs';
|
|
12
|
+
import { assertLocalDatabase, boundSupabaseLauncher, nativeDatabaseEnvironment } from '../stack/target-binding.mjs';
|
|
12
13
|
|
|
13
14
|
const configError = (reason) => refusal('DB_CONFIG_INVALID', reason);
|
|
14
15
|
const encrypted = (value) => typeof value === 'string' && value.includes('encrypted:');
|
|
@@ -96,7 +97,7 @@ function targetIdentity(config, env) {
|
|
|
96
97
|
}
|
|
97
98
|
|
|
98
99
|
/** Snapshot only in memory; no plaintext configuration, values or hashes reach the result. */
|
|
99
|
-
function inputs(ctx) {
|
|
100
|
+
function inputs(ctx, nativeTarget = null) {
|
|
100
101
|
const root = path.resolve(ctx.cwd);
|
|
101
102
|
requireDirectory(root);
|
|
102
103
|
requireDirectory(path.join(root, 'supabase'));
|
|
@@ -111,6 +112,10 @@ function inputs(ctx) {
|
|
|
111
112
|
requirePlainConfig(config);
|
|
112
113
|
for (const value of Object.values(ctx.env)) if (encrypted(value)) throw configError('local migration does not decrypt encrypted environment values');
|
|
113
114
|
const { values: env, files: envFiles } = projectEnvironment(root, ctx.env);
|
|
115
|
+
if (nativeTarget && (config.experimental?.stack === true
|
|
116
|
+
|| env.SUPABASE_EXPERIMENTAL_STACK && !/^(?:0|false)$/i.test(env.SUPABASE_EXPERIMENTAL_STACK)
|
|
117
|
+
|| env.SUPABASE_CLI_BINARY_OVERRIDE)) throw configError('the bound local target requires the declared legacy Supabase backend and installed launcher');
|
|
118
|
+
const launcher = nativeTarget ? boundSupabaseLauncher(root) : null;
|
|
114
119
|
const target = targetIdentity(config, env);
|
|
115
120
|
let names;
|
|
116
121
|
try { names = fs.readdirSync(path.join(root, MIGRATIONS_DIR)).sort(byteOrder); } catch { throw configError('local migrations are not readable'); }
|
|
@@ -127,18 +132,25 @@ function inputs(ctx) {
|
|
|
127
132
|
migrations.push([name, readFile(path.join(root, MIGRATIONS_DIR, name))]);
|
|
128
133
|
}
|
|
129
134
|
const digest = createHash('sha256').update(JSON.stringify({ root, appText, configText, envFiles, env, target, migrations })).digest('hex');
|
|
130
|
-
return { root, target, names: migrations.map(([name]) => name), digest };
|
|
135
|
+
return { root, target, names: migrations.map(([name]) => name), digest, nativeTarget, launcher };
|
|
131
136
|
}
|
|
132
137
|
|
|
133
138
|
function unchanged(ctx, snapshot) {
|
|
134
|
-
const current = inputs(ctx);
|
|
139
|
+
const current = inputs(ctx, snapshot.nativeTarget);
|
|
135
140
|
if (current.digest !== snapshot.digest) throw failure('DB_FINAL_MISMATCH', 'local migration inputs changed during the operation');
|
|
136
141
|
return current;
|
|
137
142
|
}
|
|
138
143
|
|
|
139
144
|
/** Only the specific Supabase owner can spawn; failures and malformed JSON never expose child output. */
|
|
140
145
|
function push(ctx, snapshot, dryRun) {
|
|
141
|
-
const
|
|
146
|
+
const env = snapshot.nativeTarget ? nativeDatabaseEnvironment(ctx.env, snapshot.nativeTarget,
|
|
147
|
+
{ 'SUPABASE_SERVICES_HOSTNAME': '127.0.0.1', 'SUPABASE_EXPERIMENTAL_STACK': '0' }) : ctx.env;
|
|
148
|
+
const run = snapshot.nativeTarget ? (file, args, options) => {
|
|
149
|
+
if (file !== process.execPath || args[0] !== snapshot.launcher) throw failure('DB_CLI_MISSING', 'the bound database launcher cannot fall back to a global tool');
|
|
150
|
+
return ctx.spawn(file, args, options);
|
|
151
|
+
} : ctx.spawn;
|
|
152
|
+
const result = databasePush(snapshot.root, ['--local', '--skip-vault', '--workdir', snapshot.root, '--output-format', 'json',
|
|
153
|
+
...(snapshot.nativeTarget ? ['--profile', 'supabase'] : []), ...(dryRun ? ['--dry-run'] : ['--yes'])], { env, run });
|
|
142
154
|
if (result?.error || result?.signal || result?.status !== 0) throw failure('DB_CLI_FAILED', 'the local Supabase migration command failed; database output was withheld');
|
|
143
155
|
let payload;
|
|
144
156
|
try { payload = JSON.parse(result.stdout); } catch { throw failure('DB_CLI_FAILED', 'the local migration command returned unreadable JSON'); }
|
|
@@ -156,8 +168,9 @@ function push(ctx, snapshot, dryRun) {
|
|
|
156
168
|
}
|
|
157
169
|
|
|
158
170
|
/** Preview by default; the caller admits execute only after the existing database arming check. No history repair, reset or linked fallback. */
|
|
159
|
-
export function migrateLocalDatabase(ctx, { execute = false } = {}) {
|
|
160
|
-
const snapshot = inputs(ctx);
|
|
171
|
+
export function migrateLocalDatabase(ctx, { execute = false, nativeTarget = null } = {}) {
|
|
172
|
+
const snapshot = inputs(ctx, nativeTarget);
|
|
173
|
+
assertLocalDatabase(nativeTarget, snapshot.target);
|
|
161
174
|
const pending = push(ctx, snapshot, true);
|
|
162
175
|
unchanged(ctx, snapshot);
|
|
163
176
|
let applied = [];
|
package/db/migrate.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import { firstLine, tool } from '../stack/context.mjs';
|
|
|
18
18
|
import { approvalMissing, failure, refusal } from '../stack/errors.mjs';
|
|
19
19
|
import { readDeclaration, runVerb } from '../release/index.mjs';
|
|
20
20
|
import { migrateLocalDatabase } from './local-migrate.mjs';
|
|
21
|
+
import { parseTargetBinding, assertUrlDatabase, assertBoundCustody, boundSupabaseLauncher, boundLinkedDatabaseUrl, nativeDatabaseEnvironment } from '../stack/target-binding.mjs';
|
|
21
22
|
|
|
22
23
|
const DATABASE_CODE = 'DB_CONFIG_INVALID';
|
|
23
24
|
|
|
@@ -182,17 +183,20 @@ function parseConnection(value) {
|
|
|
182
183
|
} catch { return null; }
|
|
183
184
|
}
|
|
184
185
|
|
|
185
|
-
function urlTarget(ctx, name) {
|
|
186
|
+
function urlTarget(ctx, name, binding = null) {
|
|
186
187
|
if (!KEY.test(name)) throw refusal('DB_USAGE', '--url-env must name an UPPER_CASE environment variable');
|
|
187
188
|
const value = ctx.env[name];
|
|
188
189
|
if (typeof value !== 'string' || value === '') throw refusal('DB_URL_ENV_MISSING', `${name} is not set`);
|
|
190
|
+
if (binding && (binding.mode !== 'url-env' || name !== binding.urlEnv)) throw refusal('DB_WRONG_PROJECT', 'the URL selector differs from the accepted native target');
|
|
191
|
+
assertUrlDatabase(binding, value);
|
|
189
192
|
const parsed = parseConnection(value);
|
|
190
193
|
if (!parsed) throw refusal('DB_URL_INVALID', `${name} is not a postgres connection URL`);
|
|
191
194
|
const secrets = [value, parsed.password].filter(Boolean);
|
|
192
|
-
return { kind: 'url', how: `connection string from ${name}`, url: parsed.url, env: parsed.password ? { PGPASSWORD: parsed.password } : {}, secrets };
|
|
195
|
+
return { kind: 'url', how: `connection string from ${name}`, url: parsed.url, env: parsed.password ? { PGPASSWORD: parsed.password } : {}, secrets, nativeTarget: binding, privateInputEnv: name };
|
|
193
196
|
}
|
|
194
197
|
|
|
195
|
-
function sealedPassword(ctx, spec) {
|
|
198
|
+
function sealedPassword(ctx, spec, binding = null) {
|
|
199
|
+
if (binding) assertBoundCustody(ctx.cwd, path.join(ctx.cwd, STACKS_DIR, spec.env, 'secrets', `${spec.member}.enc`));
|
|
196
200
|
let map;
|
|
197
201
|
try { map = readMap(path.join(ctx.cwd, STACKS_DIR, spec.env, 'secrets', `${spec.member}.enc`), ctx.sops); } catch { throw refusal('DB_PASSWORD_UNREADABLE', `the sealed member ${spec.member} cannot be opened`); }
|
|
198
202
|
if (typeof map[spec.key] !== 'string' || map[spec.key] === '') throw refusal('DB_PASSWORD_UNREADABLE', `the sealed member ${spec.member} has no value for ${spec.key}`);
|
|
@@ -200,24 +204,45 @@ function sealedPassword(ctx, spec) {
|
|
|
200
204
|
}
|
|
201
205
|
|
|
202
206
|
/** The database password for the linked CLI: SUPABASE_DB_PASSWORD from the environment, else the declared sealed value, else none (the CLI mints a login). */
|
|
203
|
-
function linkedPassword(ctx, decl) {
|
|
207
|
+
function linkedPassword(ctx, decl, binding = null) {
|
|
208
|
+
if (binding) {
|
|
209
|
+
if (!decl.password || decl.password.env !== binding.environment || ctx.env.SUPABASE_DB_PASSWORD !== undefined)
|
|
210
|
+
throw refusal('DB_PASSWORD_UNREADABLE', 'the bound linked target requires its declared sealed password without an ambient override');
|
|
211
|
+
return sealedPassword(ctx, decl.password, binding);
|
|
212
|
+
}
|
|
204
213
|
const given = ctx.env.SUPABASE_DB_PASSWORD;
|
|
205
214
|
if (typeof given === 'string' && given !== '') return given;
|
|
206
215
|
return decl.password === null ? null : sealedPassword(ctx, decl.password);
|
|
207
216
|
}
|
|
208
217
|
|
|
209
|
-
function linkedTarget(ctx, decl) {
|
|
218
|
+
function linkedTarget(ctx, decl, binding = null) {
|
|
219
|
+
if (binding && (binding.mode !== 'linked' || decl.projectRef !== binding.projectRef || !decl.password || decl.password.env !== binding.environment))
|
|
220
|
+
throw refusal('DB_WRONG_PROJECT', 'the linked project and sealed password must match the accepted native target');
|
|
210
221
|
let ref;
|
|
222
|
+
if (binding) assertBoundCustody(ctx.cwd, path.join(ctx.cwd, 'supabase', '.temp', 'project-ref'));
|
|
211
223
|
try { ref = fs.readFileSync(path.join(ctx.cwd, 'supabase', '.temp', 'project-ref'), 'utf8').trim(); } catch { throw refusal('DB_NOT_LINKED', 'no linked project (supabase/.temp/project-ref): run supabase link first'); }
|
|
212
224
|
if (decl.projectRef !== null && ref !== decl.projectRef) throw refusal('DB_WRONG_PROJECT', `the app is linked to ${ref}, not the declared project ${decl.projectRef}`);
|
|
213
|
-
|
|
214
|
-
|
|
225
|
+
if (binding && ref !== binding.projectRef) throw refusal('DB_WRONG_PROJECT', 'the linked project differs from the accepted native target');
|
|
226
|
+
let nativeUrl;
|
|
227
|
+
if (binding) {
|
|
228
|
+
const saved = path.join(ctx.cwd, 'supabase', '.temp', 'pooler-url');
|
|
229
|
+
const stat = fs.lstatSync(saved, { throwIfNoEntry: false });
|
|
230
|
+
if (stat) {
|
|
231
|
+
assertBoundCustody(ctx.cwd, saved);
|
|
232
|
+
if (stat.size > 8192) throw refusal('DB_WRONG_PROJECT', 'the saved native pooler endpoint exceeds its public bound');
|
|
233
|
+
}
|
|
234
|
+
nativeUrl = boundLinkedDatabaseUrl(binding, stat ? fs.readFileSync(saved, 'utf8') : null);
|
|
235
|
+
}
|
|
236
|
+
const password = linkedPassword(ctx, decl, binding);
|
|
237
|
+
if (binding) return { kind: 'url', logicalKind: 'linked', how: `linked project ${ref}`, ref, url: nativeUrl,
|
|
238
|
+
env: { PGPASSWORD: password }, secrets: [password], nativeTarget: binding };
|
|
239
|
+
return { kind: 'linked', how: `linked project ${ref}`, ref, url: null, env: password ? { SUPABASE_DB_PASSWORD: password } : {}, secrets: password ? [password] : [], nativeTarget: binding };
|
|
215
240
|
}
|
|
216
241
|
|
|
217
242
|
/** { kind, how, env, secrets, url }: the linked project of the app (default) or the connection string in the named environment variable. */
|
|
218
243
|
export function resolveTarget(ctx, decl, options) {
|
|
219
244
|
if (options.linked === true && options.urlEnv !== undefined) throw refusal('DB_USAGE', '--linked and --url-env exclude each other');
|
|
220
|
-
return options.urlEnv === undefined ? linkedTarget(ctx, decl) : urlTarget(ctx, options.urlEnv);
|
|
245
|
+
return options.urlEnv === undefined ? linkedTarget(ctx, decl, options.nativeTarget) : urlTarget(ctx, options.urlEnv, options.nativeTarget);
|
|
221
246
|
}
|
|
222
247
|
|
|
223
248
|
/** CLI arguments that select the database and the local supabase folder. */
|
|
@@ -228,7 +253,8 @@ export function requireArmed(ctx) {
|
|
|
228
253
|
if (ctx.env.STARCI_DB_ARMED !== '1') throw approvalMissing('DB_NOT_ARMED', 'set STARCI_DB_ARMED=1 to apply database changes');
|
|
229
254
|
}
|
|
230
255
|
|
|
231
|
-
const supabaseCommand = (root) => {
|
|
256
|
+
const supabaseCommand = (root, bound) => {
|
|
257
|
+
if (bound) return [process.execPath, boundSupabaseLauncher(root)];
|
|
232
258
|
const script = path.join(root, 'node_modules', 'supabase', 'dist', 'supabase.js');
|
|
233
259
|
return fs.existsSync(script) ? [process.execPath, script] : ['supabase'];
|
|
234
260
|
};
|
|
@@ -236,15 +262,41 @@ const supabaseCommand = (root) => {
|
|
|
236
262
|
/** Runs the Supabase CLI through the seam; a failure names the first stderr line with every secret scrubbed. Returns { stdout, text } (text = stdout plus stderr). */
|
|
237
263
|
export function runCli(session, args) {
|
|
238
264
|
const { ctx, target } = session;
|
|
239
|
-
const [command, ...lead] = supabaseCommand(ctx.cwd);
|
|
240
|
-
const
|
|
265
|
+
const [command, ...lead] = supabaseCommand(ctx.cwd, target.nativeTarget);
|
|
266
|
+
const env = target.nativeTarget ? nativeDatabaseEnvironment(ctx.env, target.nativeTarget, target.env, [target.privateInputEnv]) : { ...ctx.env, ...target.env };
|
|
267
|
+
const workdir = target.nativeTarget ? args[args.indexOf('--workdir') + 1] : ctx.cwd;
|
|
268
|
+
if (target.nativeTarget && !session.nativeWorkdirs?.has(workdir)) throw refusal('DB_CONFIG_INVALID', 'the bound database command requires its clean public scratch project');
|
|
269
|
+
const run = tool(ctx, command, [...lead, ...args, ...(target.nativeTarget ? ['--profile', 'supabase', '--output-format', 'json', '--agent', 'no'] : [])], { cwd: workdir, env });
|
|
241
270
|
if (run.error) throw failure('DB_CLI_MISSING', `cannot run the Supabase CLI (${scrub(run.error.message, target.secrets)})`);
|
|
242
271
|
if (run.status !== 0) throw failure('DB_CLI_FAILED', `supabase ${args.slice(0, 2).join(' ')} failed: ${scrub(firstLine(run.stderr), target.secrets)}`);
|
|
243
272
|
return { stdout: run.stdout ?? '', text: `${run.stdout ?? ''}${run.stderr ?? ''}` };
|
|
244
273
|
}
|
|
245
274
|
|
|
246
|
-
/**
|
|
247
|
-
|
|
275
|
+
/** A bound consumer requires the native machine result; unreadable output cannot become an empty database history. */
|
|
276
|
+
function boundRemoteVersions(stdout) {
|
|
277
|
+
let data;
|
|
278
|
+
try { data = JSON.parse(stdout); } catch { throw failure('DB_CLI_FAILED', 'the bound migration history is not readable JSON'); }
|
|
279
|
+
if (!Array.isArray(data?.migrations) || data.migrations.some(item => !plain(item)
|
|
280
|
+
|| typeof item.local !== 'string' || typeof item.remote !== 'string'
|
|
281
|
+
|| item.local !== '' && !STAMP.test(item.local) || item.remote !== '' && !STAMP.test(item.remote)
|
|
282
|
+
|| !item.local && !item.remote))
|
|
283
|
+
throw failure('DB_CLI_FAILED', 'the bound migration history has an unexpected shape');
|
|
284
|
+
return new Set(data.migrations.map(item => item.remote).filter(Boolean));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** The remote history as a Set of versions; bound callers require noninteractive structured output. */
|
|
288
|
+
export function listRemote(session) {
|
|
289
|
+
if (!session.target.nativeTarget) return parseMigrationList(runCli(session, ['migration', 'list', ...targetArgs(session.target, session.ctx.cwd)]).text).remote;
|
|
290
|
+
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'starci-db-'));
|
|
291
|
+
try {
|
|
292
|
+
const { model } = session;
|
|
293
|
+
buildScratch(session, scratch, model.push.map(file => ({ from: path.join(model.root, model.decl.migrationsDir, file), name: file })));
|
|
294
|
+
return boundRemoteVersions(runCli(session, ['migration', 'list', ...targetArgs(session.target, scratch)]).stdout);
|
|
295
|
+
} finally {
|
|
296
|
+
session.nativeWorkdirs?.delete(scratch);
|
|
297
|
+
fs.rmSync(scratch, { recursive: true, force: true });
|
|
298
|
+
}
|
|
299
|
+
}
|
|
248
300
|
|
|
249
301
|
/* ---- the plan */
|
|
250
302
|
|
|
@@ -288,12 +340,13 @@ const dataOf = (model, plan, remote, extra) => ({
|
|
|
288
340
|
|
|
289
341
|
/* ---- applying */
|
|
290
342
|
|
|
291
|
-
/**
|
|
343
|
+
/** Public scratch SQL/config only for bound URLs; ordinary linked commands retain their native link state. */
|
|
292
344
|
function buildScratch(session, dir, copies) {
|
|
293
345
|
fs.mkdirSync(path.join(dir, 'supabase', 'migrations'), { recursive: true });
|
|
294
346
|
fs.writeFileSync(path.join(dir, 'supabase', 'config.toml'), 'project_id = "starci-db-scratch"\n');
|
|
295
347
|
for (const copy of copies) fs.copyFileSync(copy.from, path.join(dir, 'supabase', 'migrations', copy.name));
|
|
296
348
|
if (session.target.kind === 'linked') fs.cpSync(path.join(session.ctx.cwd, 'supabase', '.temp'), path.join(dir, 'supabase', '.temp'), { recursive: true });
|
|
349
|
+
if (session.target.nativeTarget) (session.nativeWorkdirs ??= new Set()).add(dir);
|
|
297
350
|
}
|
|
298
351
|
|
|
299
352
|
const repair = (session, status, versions, dir) => runCli(session, ['migration', 'repair', '--status', status, ...versions, ...targetArgs(session.target, dir)]);
|
|
@@ -334,6 +387,7 @@ function applyPlan(session, plan) {
|
|
|
334
387
|
* STARCI_DB_ARMED=1) applies it. Already baseline + forward: `nothing to do`, no mutating call, ok.
|
|
335
388
|
*/
|
|
336
389
|
async function migrateDatabase(ctx, decl, options = {}) {
|
|
390
|
+
if (options.nativeTarget) boundSupabaseLauncher(ctx.cwd);
|
|
337
391
|
const model = buildModel(ctx.cwd, decl);
|
|
338
392
|
const session = { ctx, model, target: resolveTarget(ctx, decl, options) };
|
|
339
393
|
const remote = listRemote(session);
|
|
@@ -354,19 +408,22 @@ async function migrateDatabase(ctx, decl, options = {}) {
|
|
|
354
408
|
return { ok: true, lines: [...lines, `applied: ${pushed.length} forward migration(s) pushed; the remote history now holds ${final.size} version(s). Held back: ${model.held.join(', ') || 'nothing'}.`], data: dataOf(model, plan, remote, { executed: true, pushed, remoteCountAfter: final.size }) };
|
|
355
409
|
}
|
|
356
410
|
|
|
357
|
-
const SPEC = { boolean: ['--execute', '--json', '--linked', '--local'], value: ['--url-env'] };
|
|
411
|
+
const SPEC = { boolean: ['--execute', '--json', '--linked', '--local'], value: ['--url-env', '--target-binding'] };
|
|
358
412
|
|
|
359
413
|
/** Entry of the verb: explicit local applies active SQL; linked/url-env retains the declared cutover plan. Execute requires database arming. */
|
|
360
414
|
export function dbMigrateMain(argv, io = {}) {
|
|
361
415
|
return runVerb({
|
|
362
416
|
label: 'starci app db migrate', name: 'db migrate', code: 'DB_USAGE', io, argv, spec: SPEC,
|
|
363
417
|
run: (ctx, flags) => {
|
|
418
|
+
const nativeTarget = parseTargetBinding(flags['target-binding'], 'supabase-db');
|
|
419
|
+
if (nativeTarget && (nativeTarget.mode === 'local' ? !flags.local : nativeTarget.mode === 'linked' ? !flags.linked : flags['url-env'] !== nativeTarget.urlEnv))
|
|
420
|
+
throw refusal('DB_USAGE', 'the native database selector differs from its accepted binding');
|
|
364
421
|
if (flags.local) {
|
|
365
422
|
if (flags.linked || flags['url-env'] !== undefined) throw refusal('DB_USAGE', '--local excludes --linked and --url-env');
|
|
366
423
|
if (flags.execute) requireArmed(ctx);
|
|
367
|
-
return migrateLocalDatabase(ctx, { execute: flags.execute === true });
|
|
424
|
+
return migrateLocalDatabase(ctx, { execute: flags.execute === true, nativeTarget });
|
|
368
425
|
}
|
|
369
|
-
return migrateDatabase(ctx, loadDatabase(ctx.cwd), { execute: flags.execute === true, linked: flags.linked === true, urlEnv: flags['url-env'] });
|
|
426
|
+
return migrateDatabase(ctx, loadDatabase(ctx.cwd), { execute: flags.execute === true, linked: flags.linked === true, urlEnv: flags['url-env'], nativeTarget });
|
|
370
427
|
},
|
|
371
428
|
});
|
|
372
429
|
}
|
package/package.json
CHANGED
package/release/gate.mjs
CHANGED
|
@@ -71,7 +71,8 @@ function sonarGate(services, env) {
|
|
|
71
71
|
const organization = sonar.organization ?? (sonar.organizationVar ? env[sonar.organizationVar] : null);
|
|
72
72
|
if (organization) command.push(`-Dsonar.organization=${organization}`);
|
|
73
73
|
if (!command.every((word) => TOKEN.test(word))) throw refusal('RELEASE_SONAR_UNAVAILABLE', 'Sonar host and organization must be plain command arguments');
|
|
74
|
-
return { name: 'sonar', command, summary: null, env: { SONAR_TOKEN: env[tokenName] }, provider: sonar
|
|
74
|
+
return { name: 'sonar', command, summary: null, env: { SONAR_TOKEN: env[tokenName] }, provider: sonar,
|
|
75
|
+
credentialNames: [...new Set(['SONAR_TOKEN', tokenName])] };
|
|
75
76
|
}
|
|
76
77
|
|
|
77
78
|
function gateProblems(entry, at, seen) {
|
|
@@ -128,7 +129,7 @@ export function normalizeRelease(block, scripts = {}, services = {}, options = {
|
|
|
128
129
|
|
|
129
130
|
/** Runs git in the app root; a missing git or (unless allowFail) a non-zero exit is a failure whose message names the first stderr line. */
|
|
130
131
|
export function git(ctx, args, { allowFail = false } = {}) {
|
|
131
|
-
const run = tool(ctx, 'git', args, { cwd: ctx.cwd });
|
|
132
|
+
const run = tool(ctx, 'git', args, { cwd: ctx.cwd, env: ctx.env });
|
|
132
133
|
if (run.error) throw failure('RELEASE_GIT_MISSING', `cannot run git (${run.error.message})`);
|
|
133
134
|
if (run.status !== 0 && !allowFail) throw failure('RELEASE_GIT_FAILED', `git ${args.slice(0, 2).join(' ')}: ${firstLine(run.stderr)}`);
|
|
134
135
|
return { code: run.status, stdout: String(run.stdout ?? '').trim(), stderr: String(run.stderr ?? '') };
|
|
@@ -236,11 +237,18 @@ async function runAvailableGate(ctx, gate, options) {
|
|
|
236
237
|
summary: null, log: path.resolve(log), logSha256: sha256(log), env: Object.keys(gate.env) };
|
|
237
238
|
}
|
|
238
239
|
|
|
239
|
-
|
|
240
|
+
const withoutCredentials = (values, credentialNames) => {
|
|
241
|
+
const selected = new Set(credentialNames);
|
|
242
|
+
return Object.fromEntries(Object.entries(values ?? {}).filter(([key]) => !selected.has(key.toUpperCase())));
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
function runGate(ctx, gate, { dir, platform, credentialNames }) {
|
|
240
246
|
const log = path.join(dir, `${gate.name}.log`);
|
|
241
247
|
const started = ctx.now();
|
|
242
248
|
const [command, args] = launch(gate.command, platform);
|
|
243
|
-
|
|
249
|
+
// The native scanner alone consumes Sonar credentials; ordinary gates cannot inherit or reintroduce them.
|
|
250
|
+
const env = { ...ctx.env, ...(gate.provider ? gate.env : withoutCredentials(gate.env, credentialNames)) };
|
|
251
|
+
const run = tool(ctx, command, args, { cwd: ctx.cwd, env });
|
|
244
252
|
const output = `${run.stdout ?? ''}${run.stderr ?? ''}`;
|
|
245
253
|
const text = gate.name === 'sonar' && gate.env.SONAR_TOKEN ? output.replaceAll(gate.env.SONAR_TOKEN, '[REDACTED]') : output;
|
|
246
254
|
fs.writeFileSync(log, text);
|
|
@@ -345,6 +353,8 @@ function summaryOf(result) {
|
|
|
345
353
|
*/
|
|
346
354
|
export async function releaseGate(ctx, config, options = {}) {
|
|
347
355
|
if (options.sonar) config = { ...config, gates: [...config.gates.filter((gate) => gate.name !== 'sonar'), sonarGate(config.services, ctx.env)] };
|
|
356
|
+
const credentialNames = config.gates.find(gate => gate.provider)?.credentialNames ?? ['SONAR_TOKEN', config.services?.sonar?.token].filter(Boolean);
|
|
357
|
+
ctx = Object.create(ctx, {env: {value: withoutCredentials(ctx.env, credentialNames)}});
|
|
348
358
|
// The CI verdict of a release is the candidate run of its own sha, judged by `starci app release push` (candidate-ci.mjs, OWNER RULING candidate-ci 2026-10-18):
|
|
349
359
|
// the gate no longer refuses over the latest default-branch run.
|
|
350
360
|
const facts = inspect(ctx, config, options);
|
|
@@ -355,7 +365,7 @@ export async function releaseGate(ctx, config, options = {}) {
|
|
|
355
365
|
const base = { head: facts.head, tag: facts.chosen.tag, record: recordPath, gates: config.gates.map((gate) => gate.name) };
|
|
356
366
|
if (facts.red.length > 0) return { ok: false, lines: [...plan, ...facts.red.map((item) => `RED: ${item}`), 'nothing was run.'], data: { ...base, red: facts.red, executed: false } };
|
|
357
367
|
if (options.execute !== true) return { ok: true, lines: [...plan, '', 'dry run: no gate was run, nothing written. preconditions OK.'], data: { ...base, red: [], executed: false } };
|
|
358
|
-
const results = await runAll(ctx, config, { dir, platform: options.platform ?? process.platform });
|
|
368
|
+
const results = await runAll(ctx, config, { dir, platform: options.platform ?? process.platform, credentialNames });
|
|
359
369
|
const report = results.map(summaryOf);
|
|
360
370
|
const failed = results.filter((result) => !result.pass && !result.skipped);
|
|
361
371
|
if (failed.length > 0) return { ok: false, lines: [...plan, ...report, `gate(s) red: ${failed.map((item) => item.name).join(', ')}. No L4 record, no tag.`], data: { ...base, executed: true, results } };
|
|
@@ -29,14 +29,14 @@ pins:
|
|
|
29
29
|
source: packages/grammar/package.json
|
|
30
30
|
why: 'the grammar front ends read: the brand layer sets `--font-sans` and `--font-mono` and the grammar reads them; it carries the Input tel kind, the copyable and read-only Input, the IconButton disclosure props and count badge, ChatBubble, FillViewport, Textarea mentions and Enter-to-send, the leading PrimaryRailLayout rail, the plate-less Image, ListRow, the soft Alert, Progress steps, the floating primary IconButton, FlowGraph, the STATE-8 loading views and the bordered selectable TagGroup chips. The runtime source version wins for a @starci package.'
|
|
31
31
|
'@starci/eslint-canon-be':
|
|
32
|
-
version: 3.4.
|
|
32
|
+
version: 3.4.17
|
|
33
33
|
group: starci
|
|
34
34
|
install: registry
|
|
35
35
|
side: be
|
|
36
36
|
source: packages/eslint/be/package.json
|
|
37
37
|
why: 'the back-end ESLint canon: `loadHfs(import.meta.url)` of be/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the be side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; `starciBeConfig({ hfs: loadHfs(import.meta.url) })` is the typed factory and the BE-CONVENTION laws are its rules.'
|
|
38
38
|
'@starci/eslint-canon-fe':
|
|
39
|
-
version: 8.4.
|
|
39
|
+
version: 8.4.17
|
|
40
40
|
group: starci
|
|
41
41
|
install: registry
|
|
42
42
|
side: fe
|
|
@@ -76,14 +76,14 @@ pins:
|
|
|
76
76
|
source: packages/test-world/package.json
|
|
77
77
|
why: 'the shared e2e library of every back end (R47, R48), a devDependency of every back end: the warm stack behind toxiproxy, the network-edge fakes, the Nest boot, the typed useTestWorld handle, useSandbox for contract specs and the outage lock. Kafka is real own infra (the one digest-pinned apache/kafka KRaft image, a broker listener and proxy per slot, slot-prefixed topics, consumer groups and client ids) and Postgres is schema-per-context (a schema and a login role per context in a shared database). Each worker has its own data slot (<ns>_w<k>) with its own databases, Keycloak realm, leased Redis DB, bucket/collection/topic prefixes, fakes host, toxiproxy proxies and outage lock; a run provisions min(jest workers, workers cap, default 2) slots, and state file protocol 2 pairs exactly with @starci/jest-preset (TEST_WORLD_PAIR_MISMATCH otherwise). A modules world boots real peer apps beside its modules, world.apps.<name>.during(fn) is the outage of a peer app, w.keycloak.clientSecret(client) answers the run-generated secret of a confidential realm client, world.keycloak.events/sessions read the realm user events and live sessions, world.infra.postgresql.connection(name) takes one database connection down while the others serve, and world.resolve and scope.resolve take any Nest token. Every path a declaration names (`stack`, seeds, the realm, Dockerfiles) resolves from the app root (the directory of hfs.json); the globalSetup registers the path aliases as TypeScript resolves them. Its CLI main is exported at ./cli for starci app stack.'
|
|
78
78
|
'@starci/cli':
|
|
79
|
-
version: 1.7.
|
|
79
|
+
version: 1.7.11
|
|
80
80
|
group: starci
|
|
81
81
|
install: registry
|
|
82
82
|
side: both
|
|
83
83
|
source: packages/cli/package.json
|
|
84
84
|
why: 'the only StarCi command package; product repositories install it for starci app, while @starci/hfs remains its pinned transitive implementation.'
|
|
85
85
|
'@starci/hfs':
|
|
86
|
-
version: 4.7.
|
|
86
|
+
version: 4.7.11
|
|
87
87
|
group: starci
|
|
88
88
|
install: registry
|
|
89
89
|
side: both
|
|
@@ -105,6 +105,8 @@ properties:
|
|
|
105
105
|
status: {enum: [deferred, supported]}
|
|
106
106
|
reason: {type: string, minLength: 1}
|
|
107
107
|
ci: {$ref: '#/$defs/ciDeclaration'}
|
|
108
|
+
operations:
|
|
109
|
+
$ref: provision-operation.schema.yaml
|
|
108
110
|
services:
|
|
109
111
|
description: >-
|
|
110
112
|
The delivery, quality and platform services this repository's code and CI use - NOT Compose
|