@eide/foir-cli 0.17.0 → 0.18.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/cli.js CHANGED
@@ -9115,7 +9115,9 @@ function classToLabel(n) {
9115
9115
  }
9116
9116
 
9117
9117
  // src/commands/secrets.ts
9118
+ import { existsSync as existsSync6 } from "fs";
9118
9119
  import { promises as fs5 } from "fs";
9120
+ import { resolve as resolvePath } from "path";
9119
9121
  function registerSecretsCommands(program2, globalOpts) {
9120
9122
  const secrets = program2.command("secrets").description("Manage vault secrets");
9121
9123
  secrets.command("put").description("Store a new secret and print its ref").option("--label <label>", "Optional human-readable label").option("--app <name>", "Owner: app name (defaults to project-owned)").option("--file <path>", "Read plaintext from file (binary-safe)").option("--value <plaintext>", "Plaintext value (string only; prefer --file for binary)").action(
@@ -9233,6 +9235,110 @@ function registerSecretsCommands(program2, globalOpts) {
9233
9235
  success(`Restored ${ref}`);
9234
9236
  })
9235
9237
  );
9238
+ secrets.command("push").description(
9239
+ "Reconcile foir.secrets.ts against the project vault: create missing secrets, optionally rotate existing ones"
9240
+ ).option("--config <path>", "Path to foir.secrets.ts (default: auto-discover)").option("--plaintext <path>", "Path to local.foir.secrets.ts (default: auto-discover)").option("--rotate", "Rotate plaintext for secrets that already exist").option("--dry-run", "Show what would change without calling PutSecret/RotateSecret").action(
9241
+ withErrorHandler(globalOpts, async (cmdOpts) => {
9242
+ const opts = globalOpts();
9243
+ const resolved = await requireProject2(opts);
9244
+ const configPath = await resolveSecretsConfigPath(
9245
+ typeof cmdOpts.config === "string" ? cmdOpts.config : void 0
9246
+ );
9247
+ const plaintextPath = await resolvePlaintextPath(
9248
+ typeof cmdOpts.plaintext === "string" ? cmdOpts.plaintext : void 0
9249
+ );
9250
+ const declared = await loadConfig(configPath);
9251
+ validateSecretsConfig(declared, configPath);
9252
+ const plaintextSource = plaintextPath ? await loadConfig(plaintextPath) : {};
9253
+ const dryRun = !!cmdOpts["dry-run"];
9254
+ const rotate = !!cmdOpts.rotate;
9255
+ const client = await createPlatformClient(opts);
9256
+ const groups = groupDeclarations(declared.secrets);
9257
+ const planEntries = [];
9258
+ for (const group of groups) {
9259
+ const existing = await client.secrets.list({
9260
+ tenantId: resolved.project.tenantId,
9261
+ projectId: resolved.project.id,
9262
+ ownerKind: group.ownerKind,
9263
+ ownerId: group.ownerId ?? "",
9264
+ includeSoftDeleted: false
9265
+ });
9266
+ const existingByLabel = new Map(
9267
+ existing.filter((s) => s.label).map((s) => [s.label, s])
9268
+ );
9269
+ for (const decl of group.decls) {
9270
+ const remote = existingByLabel.get(decl.label);
9271
+ if (remote && !rotate) {
9272
+ planEntries.push({ decl, action: "skip", existingRef: remote.ref });
9273
+ continue;
9274
+ }
9275
+ if (remote && rotate) {
9276
+ planEntries.push({ decl, action: "rotate", existingRef: remote.ref });
9277
+ continue;
9278
+ }
9279
+ planEntries.push({ decl, action: "create" });
9280
+ }
9281
+ }
9282
+ const needsPlaintext = planEntries.filter(
9283
+ (e) => e.action === "create" || e.action === "rotate"
9284
+ );
9285
+ const missingPlaintext = needsPlaintext.map((e) => e.decl.label).filter((label) => !(label in plaintextSource));
9286
+ if (missingPlaintext.length > 0 && !dryRun) {
9287
+ throw new Error(
9288
+ `Missing plaintext for ${missingPlaintext.length} secret(s) in ${plaintextPath ?? "local.foir.secrets.ts"}: ${missingPlaintext.join(", ")}. Add an entry for each label, or pass --dry-run to preview without resolving plaintext.`
9289
+ );
9290
+ }
9291
+ if (dryRun) {
9292
+ formatPushPlan(planEntries, opts);
9293
+ return;
9294
+ }
9295
+ const results = [];
9296
+ for (const entry of planEntries) {
9297
+ if (entry.action === "skip") {
9298
+ results.push({ ...entry, ref: entry.existingRef });
9299
+ continue;
9300
+ }
9301
+ const raw = plaintextSource[entry.decl.label];
9302
+ if (raw === void 0) {
9303
+ throw new Error(`unreachable: missing plaintext for ${entry.decl.label}`);
9304
+ }
9305
+ const plaintext = toUint8Array(raw);
9306
+ if (entry.action === "create") {
9307
+ const ref = await client.secrets.put({
9308
+ tenantId: resolved.project.tenantId,
9309
+ projectId: resolved.project.id,
9310
+ ownerKind: ownerKindFromString(entry.decl.ownerKind),
9311
+ ownerId: entry.decl.ownerId,
9312
+ label: entry.decl.label,
9313
+ plaintext
9314
+ });
9315
+ results.push({ ...entry, ref });
9316
+ } else {
9317
+ const newRef = await client.secrets.rotate(entry.existingRef, plaintext);
9318
+ results.push({ ...entry, ref: newRef });
9319
+ }
9320
+ }
9321
+ if (opts.json || opts.jsonl) {
9322
+ formatOutput(
9323
+ results.map((r) => ({
9324
+ label: r.decl.label,
9325
+ ownerKind: r.decl.ownerKind,
9326
+ ownerId: r.decl.ownerId,
9327
+ action: r.action,
9328
+ ref: r.ref
9329
+ })),
9330
+ opts
9331
+ );
9332
+ return;
9333
+ }
9334
+ const created = results.filter((r) => r.action === "create").length;
9335
+ const rotated = results.filter((r) => r.action === "rotate").length;
9336
+ const skipped = results.filter((r) => r.action === "skip").length;
9337
+ success(
9338
+ `secrets push: ${created} created, ${rotated} rotated, ${skipped} unchanged`
9339
+ );
9340
+ })
9341
+ );
9236
9342
  secrets.command("purge").description("Drop every soft-deleted secret past its TTL (admin-only)").option("--confirm", "Skip confirmation prompt").action(
9237
9343
  withErrorHandler(globalOpts, async (cmdOpts) => {
9238
9344
  const opts = globalOpts();
@@ -9295,6 +9401,121 @@ function tsToString(t) {
9295
9401
  const nanos = t.nanos ?? 0;
9296
9402
  return new Date(seconds * 1e3 + Math.floor(nanos / 1e6)).toISOString();
9297
9403
  }
9404
+ var SECRETS_CONFIG_NAMES = [
9405
+ "foir.secrets.ts",
9406
+ "foir.secrets.js",
9407
+ "foir.secrets.mjs",
9408
+ "foir.secrets.json"
9409
+ ];
9410
+ var PLAINTEXT_CONFIG_NAMES = [
9411
+ "local.foir.secrets.ts",
9412
+ "local.foir.secrets.js",
9413
+ "local.foir.secrets.mjs",
9414
+ "local.foir.secrets.json"
9415
+ ];
9416
+ async function resolveSecretsConfigPath(explicit) {
9417
+ if (explicit) {
9418
+ if (!existsSync6(explicit)) {
9419
+ throw new Error(`Secrets config not found: ${explicit}`);
9420
+ }
9421
+ return resolvePath(explicit);
9422
+ }
9423
+ for (const name of SECRETS_CONFIG_NAMES) {
9424
+ const path3 = resolvePath(process.cwd(), name);
9425
+ if (existsSync6(path3)) return path3;
9426
+ }
9427
+ throw new Error(
9428
+ `No secrets config found. Looked for: ${SECRETS_CONFIG_NAMES.join(", ")}.`
9429
+ );
9430
+ }
9431
+ async function resolvePlaintextPath(explicit) {
9432
+ if (explicit) {
9433
+ if (!existsSync6(explicit)) {
9434
+ throw new Error(`Plaintext file not found: ${explicit}`);
9435
+ }
9436
+ return resolvePath(explicit);
9437
+ }
9438
+ for (const name of PLAINTEXT_CONFIG_NAMES) {
9439
+ const path3 = resolvePath(process.cwd(), name);
9440
+ if (existsSync6(path3)) return path3;
9441
+ }
9442
+ return null;
9443
+ }
9444
+ function validateSecretsConfig(cfg, path3) {
9445
+ if (!cfg || !Array.isArray(cfg.secrets)) {
9446
+ throw new Error(`${path3}: default export must be { secrets: [...] }`);
9447
+ }
9448
+ const seen = /* @__PURE__ */ new Set();
9449
+ for (const decl of cfg.secrets) {
9450
+ if (!decl.label) {
9451
+ throw new Error(`${path3}: every secret declaration needs a label`);
9452
+ }
9453
+ if (decl.ownerKind !== "project" && decl.ownerKind !== "app") {
9454
+ throw new Error(
9455
+ `${path3}: ownerKind must be "project" or "app" for label ${decl.label}, got ${decl.ownerKind}`
9456
+ );
9457
+ }
9458
+ if (decl.ownerKind === "app" && !decl.ownerId) {
9459
+ throw new Error(
9460
+ `${path3}: app-owned secret ${decl.label} requires ownerId`
9461
+ );
9462
+ }
9463
+ const dedupeKey = `${decl.ownerKind}|${decl.ownerId ?? ""}|${decl.label}`;
9464
+ if (seen.has(dedupeKey)) {
9465
+ throw new Error(`${path3}: duplicate secret declaration: ${dedupeKey}`);
9466
+ }
9467
+ seen.add(dedupeKey);
9468
+ }
9469
+ }
9470
+ function groupDeclarations(decls) {
9471
+ const byKey = /* @__PURE__ */ new Map();
9472
+ for (const decl of decls) {
9473
+ const key = `${decl.ownerKind}|${decl.ownerId ?? ""}`;
9474
+ const existing = byKey.get(key);
9475
+ if (existing) {
9476
+ existing.decls.push(decl);
9477
+ } else {
9478
+ byKey.set(key, {
9479
+ ownerKind: ownerKindFromString(decl.ownerKind),
9480
+ ownerId: decl.ownerId,
9481
+ decls: [decl]
9482
+ });
9483
+ }
9484
+ }
9485
+ return Array.from(byKey.values());
9486
+ }
9487
+ function ownerKindFromString(s) {
9488
+ return s === "app" ? OwnerKind.APP : OwnerKind.PROJECT;
9489
+ }
9490
+ function toUint8Array(v) {
9491
+ if (v instanceof Uint8Array) return v;
9492
+ return new TextEncoder().encode(v);
9493
+ }
9494
+ function formatPushPlan(plan, opts) {
9495
+ if (opts.json || opts.jsonl) {
9496
+ formatOutput(
9497
+ plan.map((e) => ({
9498
+ label: e.decl.label,
9499
+ ownerKind: e.decl.ownerKind,
9500
+ ownerId: e.decl.ownerId,
9501
+ action: e.action,
9502
+ existingRef: e.action === "create" ? void 0 : e.existingRef
9503
+ })),
9504
+ opts
9505
+ );
9506
+ return;
9507
+ }
9508
+ if (plan.length === 0) {
9509
+ warn("No secrets declared.");
9510
+ return;
9511
+ }
9512
+ for (const entry of plan) {
9513
+ const owner = entry.decl.ownerKind === "app" ? `app:${entry.decl.ownerId}` : "project";
9514
+ const action = entry.action.padEnd(7);
9515
+ const ref = entry.action === "create" ? "" : entry.existingRef;
9516
+ console.log(`${action} ${owner.padEnd(24)} ${entry.decl.label.padEnd(32)} ${ref}`);
9517
+ }
9518
+ }
9298
9519
 
9299
9520
  // src/cli.ts
9300
9521
  var __filename = fileURLToPath(import.meta.url);
@@ -354,5 +354,38 @@ declare function defineAuthProvider(provider: ApplyConfigAuthProviderInput): App
354
354
  declare function defineHook(hook: ApplyConfigHookInput): ApplyConfigHookInput;
355
355
  /** Define an editor placement (sidebar or main-editor tab). */
356
356
  declare function definePlacement(placement: ApplyConfigPlacementInput): ApplyConfigPlacementInput;
357
+ type SecretOwnerKind = 'project' | 'app';
358
+ /**
359
+ * One declared secret. `label` is the human-readable handle the
360
+ * reconciler uses to look the secret up in the vault — no opaque ref
361
+ * is committed. `ownerId` is required for app-owned secrets and ignored
362
+ * for project-owned ones.
363
+ */
364
+ interface SecretDeclaration {
365
+ ownerKind: SecretOwnerKind;
366
+ ownerId?: string;
367
+ label: string;
368
+ }
369
+ /** Shape of a `foir.secrets.ts` default export. */
370
+ interface FoirSecretsConfig {
371
+ secrets: SecretDeclaration[];
372
+ }
373
+ /**
374
+ * Type-safe identity helper for `foir.secrets.ts`:
375
+ *
376
+ * ```ts
377
+ * import { defineSecrets } from '@eide/foir-cli/configs';
378
+ * export default defineSecrets({
379
+ * secrets: [
380
+ * { ownerKind: 'project', label: 'deepl_api_key' },
381
+ * ],
382
+ * });
383
+ * ```
384
+ *
385
+ * Plaintext lives in a sibling `local.foir.secrets.ts` (gitignored)
386
+ * keyed by label, or in env vars. Production never runs the
387
+ * reconciler — operators set production secrets through the admin UI.
388
+ */
389
+ declare function defineSecrets(config: FoirSecretsConfig): FoirSecretsConfig;
357
390
 
358
- export { type AppInput, type AppPlacementFieldChoiceInput, type AppSinkMappingInput, type AppSourceMappingInput, type ApplyConfigApiKeyInput, type ApplyConfigAuthProviderInput, type ApplyConfigHookInput, type ApplyConfigInput, type ApplyConfigModelInput, type ApplyConfigOperationInput, type ApplyConfigPlacementInput, type ApplyConfigProjectInput, type ApplyConfigProjectSettingsInput, type ApplyConfigScheduleInput, type ApplyConfigSegmentInput, type ExpressionPrecondition, type FieldDefinitionInput, type Precondition, type QuotaRule, type SegmentPrecondition, type SelectFieldConfig, type SelectFieldDefinitionInput, defineAuthProvider, defineConfig, defineField, defineHook, defineModel, defineOperation, definePlacement, defineSchedule, defineSegment, defineSelectField };
391
+ export { type AppInput, type AppPlacementFieldChoiceInput, type AppSinkMappingInput, type AppSourceMappingInput, type ApplyConfigApiKeyInput, type ApplyConfigAuthProviderInput, type ApplyConfigHookInput, type ApplyConfigInput, type ApplyConfigModelInput, type ApplyConfigOperationInput, type ApplyConfigPlacementInput, type ApplyConfigProjectInput, type ApplyConfigProjectSettingsInput, type ApplyConfigScheduleInput, type ApplyConfigSegmentInput, type ExpressionPrecondition, type FieldDefinitionInput, type FoirSecretsConfig, type Precondition, type QuotaRule, type SecretDeclaration, type SecretOwnerKind, type SegmentPrecondition, type SelectFieldConfig, type SelectFieldDefinitionInput, defineAuthProvider, defineConfig, defineField, defineHook, defineModel, defineOperation, definePlacement, defineSchedule, defineSecrets, defineSegment, defineSelectField };
@@ -29,6 +29,9 @@ function defineHook(hook) {
29
29
  function definePlacement(placement) {
30
30
  return placement;
31
31
  }
32
+ function defineSecrets(config) {
33
+ return config;
34
+ }
32
35
  export {
33
36
  defineAuthProvider,
34
37
  defineConfig,
@@ -38,6 +41,7 @@ export {
38
41
  defineOperation,
39
42
  definePlacement,
40
43
  defineSchedule,
44
+ defineSecrets,
41
45
  defineSegment,
42
46
  defineSelectField
43
47
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eide/foir-cli",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Universal platform CLI for Foir platform",
5
5
  "type": "module",
6
6
  "publishConfig": {