@everystack/cli 0.4.61 → 0.4.65

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.61",
3
+ "version": "0.4.65",
4
4
  "description": "CLI and OTA updates for Expo apps on everystack",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -23,70 +23,87 @@
23
23
  ],
24
24
  "exports": {
25
25
  ".": {
26
+ "source": "./src/index.ts",
26
27
  "types": "./src/index.ts",
27
28
  "default": "./src/index.ts"
28
29
  },
29
30
  "./model": {
31
+ "source": "./src/cli/model-api.ts",
30
32
  "types": "./src/cli/model-api.ts",
31
33
  "default": "./src/cli/model-api.ts"
32
34
  },
33
35
  "./audit": {
36
+ "source": "./src/cli/audit-api.ts",
34
37
  "types": "./src/cli/audit-api.ts",
35
38
  "default": "./src/cli/audit-api.ts"
36
39
  },
37
40
  "./apply": {
41
+ "source": "./src/cli/apply-execute.ts",
38
42
  "types": "./src/cli/apply-execute.ts",
39
43
  "default": "./src/cli/apply-execute.ts"
40
44
  },
41
45
  "./db-source": {
46
+ "source": "./src/cli/db-source.ts",
42
47
  "types": "./src/cli/db-source.ts",
43
48
  "default": "./src/cli/db-source.ts"
44
49
  },
45
50
  "./reconcile": {
51
+ "source": "./src/reconcile.ts",
46
52
  "types": "./src/reconcile.ts",
47
53
  "default": "./src/reconcile.ts"
48
54
  },
49
55
  "./refresh": {
56
+ "source": "./src/refresh.ts",
50
57
  "types": "./src/refresh.ts",
51
58
  "default": "./src/refresh.ts"
52
59
  },
53
60
  "./backfill": {
61
+ "source": "./src/backfill.ts",
54
62
  "types": "./src/backfill.ts",
55
63
  "default": "./src/backfill.ts"
56
64
  },
57
65
  "./exec": {
66
+ "source": "./src/exec.ts",
58
67
  "types": "./src/exec.ts",
59
68
  "default": "./src/exec.ts"
60
69
  },
61
70
  "./audit/source": {
71
+ "source": "./src/cli/audit-source-api.ts",
62
72
  "types": "./src/cli/audit-source-api.ts",
63
73
  "default": "./src/cli/audit-source-api.ts"
64
74
  },
65
75
  "./runbook": {
76
+ "source": "./src/cli/runbook/index.ts",
66
77
  "types": "./src/cli/runbook/index.ts",
67
78
  "default": "./src/cli/runbook/index.ts"
68
79
  },
69
80
  "./client": {
81
+ "source": "./src/client/index.ts",
70
82
  "types": "./src/client/index.ts",
71
83
  "default": "./src/client/index.ts"
72
84
  },
73
85
  "./schema": {
86
+ "source": "./src/schema.ts",
74
87
  "types": "./src/schema.ts",
75
88
  "default": "./src/schema.ts"
76
89
  },
77
90
  "./handler": {
91
+ "source": "./src/handler/index.ts",
78
92
  "types": "./src/handler/index.ts",
79
93
  "default": "./src/handler/index.ts"
80
94
  },
81
95
  "./plugin": {
96
+ "source": "./src/plugin.ts",
82
97
  "types": "./src/plugin.ts",
83
98
  "default": "./src/plugin.ts"
84
99
  },
85
100
  "./env": {
101
+ "source": "./src/env.ts",
86
102
  "types": "./src/env.ts",
87
103
  "default": "./src/env.js"
88
104
  },
89
105
  "./sst": {
106
+ "source": "./src/sst.ts",
90
107
  "types": "./src/sst.ts",
91
108
  "default": "./src/sst.ts"
92
109
  }
@@ -160,7 +177,7 @@
160
177
  "jest": "29.7.0",
161
178
  "react": "19.2.0",
162
179
  "ts-jest": "29.4.9",
163
- "@everystack/server": "0.4.22"
180
+ "@everystack/server": "0.4.26"
164
181
  },
165
182
  "scripts": {
166
183
  "test": "jest",
package/src/cli/aws.ts CHANGED
@@ -734,10 +734,14 @@ export async function describeRdsSnapshots(
734
734
  *
735
735
  * The call is a no-code-upload configuration change — it completes in ~2s,
736
736
  * and Lambda drains in-flight requests before retiring each environment.
737
+ *
738
+ * Optional extraEnv merges additional env vars (e.g. rotated credentials).
739
+ * Existing env vars are preserved (read-modify-write).
737
740
  */
738
741
  export async function rollingRestart(
739
742
  region: string,
740
743
  functionName: string,
744
+ extraEnv?: Record<string, string>,
741
745
  ): Promise<{ previousGen: string; newGen: string }> {
742
746
  const client = await getLambda(region);
743
747
  const {
@@ -755,7 +759,7 @@ export async function rollingRestart(
755
759
  await client.send(new UpdateFunctionConfigurationCommand({
756
760
  FunctionName: functionName,
757
761
  Environment: {
758
- Variables: { ...env, EVERYSTACK_GEN: newGen },
762
+ Variables: { ...env, ...extraEnv, EVERYSTACK_GEN: newGen },
759
763
  },
760
764
  }));
761
765
 
@@ -258,7 +258,7 @@ export function buildProvisionSecretPlan(args: {
258
258
  * same seam as pipeline-loader). */
259
259
  export interface ServerProvision {
260
260
  runProvision(
261
- payload: { authPassword?: string; adminPassword?: string; searchPath?: string[] },
261
+ payload: { authPassword?: string; adminPassword?: string; searchPath?: string[]; rotationPairs?: boolean },
262
262
  deps: {
263
263
  execute: (sql: string) => Promise<unknown>;
264
264
  connection?: { host: string; port?: number | string; database: string } | null;
@@ -471,12 +471,277 @@ export function parseUrlConnection(url: string): { host: string; port: string; d
471
471
  };
472
472
  }
473
473
 
474
+ /**
475
+ * db:provision --rotate — swap the active A/B role for one or both credential slots.
476
+ *
477
+ * Steps: preflight master → detect active → provision inactive → write SSM →
478
+ * rolling restart → verify origin → retire old → drain sessions.
479
+ */
480
+ async function rotateCredentials(flags: Record<string, string>): Promise<void> {
481
+ const stage = flags.stage!;
482
+ const graceMs = (flags.grace ? Number(flags.grace) : 60) * 1000;
483
+
484
+ step('Resolving deployed config...');
485
+ let config;
486
+ try {
487
+ config = await resolveConfig(stage);
488
+ } catch (err: any) {
489
+ fail(err.message);
490
+ process.exit(1);
491
+ }
492
+
493
+ const { loadSstSecrets, putSstSecrets } = await import('../utils/secrets');
494
+ const { parseAppName } = await import('../discover');
495
+ const secretsCtx = { appName: await parseAppName(), stage, region: config.region };
496
+ let stageSecrets: Record<string, string> = {};
497
+ try {
498
+ stageSecrets = await loadSstSecrets(secretsCtx);
499
+ } catch (err: any) {
500
+ fail(`Cannot read stage secrets: ${err.message}`);
501
+ process.exit(1);
502
+ }
503
+
504
+ // Step 0: preflight — verify the operator connection works (break-glass path).
505
+ step('Preflight: verifying operator connection...');
506
+ const opsFn = opsFunction(config);
507
+ try {
508
+ const result = await invokeAction(config.region, opsFn, 'db:query', { sql: 'SELECT current_user' });
509
+ if ((result as any)?.error) throw new Error((result as any).error);
510
+ success('Operator connection verified.');
511
+ } catch (err: any) {
512
+ fail(`Operator connection failed: ${err.message}`);
513
+ info('Fix the operator connection before rotating.');
514
+ process.exit(1);
515
+ }
516
+
517
+ // All DB operations run through the ops Lambda (inside the VPC).
518
+ const rotate = async (action: string, payload?: Record<string, unknown>) => {
519
+ const result = await invokeAction(config.region, opsFn, 'db:rotate', { action, ...payload });
520
+ if ((result as any)?.error) throw new Error((result as any).error);
521
+ return result as any;
522
+ };
523
+
524
+ try {
525
+
526
+ // Detect which slots to rotate.
527
+ const slotFlag = flags.slot || 'both';
528
+ if (!['api', 'ops', 'both'].includes(slotFlag)) {
529
+ fail(`Invalid --slot: ${slotFlag}. Use api, ops, or both.`);
530
+ process.exit(1);
531
+ }
532
+ const slots: Array<{ base: string; secretKeys: string[] }> = [];
533
+ if (slotFlag === 'api' || slotFlag === 'both') {
534
+ slots.push({ base: 'authenticator', secretKeys: ['DatabaseUrl', 'ReadDatabaseUrl'] });
535
+ }
536
+ if (slotFlag === 'ops' || slotFlag === 'both') {
537
+ slots.push({ base: 'migrator', secretKeys: ['AdminDatabaseUrl'] });
538
+ }
539
+
540
+ const { randomBytes } = await import('node:crypto');
541
+
542
+ // Resolve host/port/database from the ops Lambda (same as db:provision does).
543
+ let conn: { host: string; port: string; database: string } | undefined;
544
+ try {
545
+ const psqlInfo = await invokeAction(config.region, opsFn, 'db:psql', {});
546
+ if ((psqlInfo as any)?.host) conn = psqlInfo as any;
547
+ } catch { /* handled below */ }
548
+ if (!conn?.host) {
549
+ fail('Could not resolve database host from the ops Lambda.');
550
+ process.exit(1);
551
+ }
552
+ const sourceUrl = stageSecrets.ADMIN_DATABASE_URL ?? stageSecrets.AdminDatabaseUrl
553
+ ?? stageSecrets.DATABASE_URL ?? stageSecrets.DatabaseUrl;
554
+
555
+ // Track the old active role for each slot so retire uses the RIGHT role (F1 fix).
556
+ const oldActiveRoles: Array<{ base: string; oldRole: string }> = [];
557
+ // Track credential env overrides to set during rolling restart.
558
+ const credEnvOverrides: Record<string, string> = {};
559
+
560
+ for (const { base, secretKeys } of slots) {
561
+ step(`Rotating ${base}...`);
562
+
563
+ const detected = await rotate('detect', { baseRole: base });
564
+ if (!detected || (!detected.activeRole && !detected.inactiveRole)) {
565
+ fail(`No rotation pairs found for ${base}. Run db:provision --rotation-pairs first.`);
566
+ process.exit(1);
567
+ }
568
+ info(` Active: ${detected.activeRole}, inactive: ${detected.inactiveRole}`);
569
+ oldActiveRoles.push({ base, oldRole: detected.activeRole });
570
+
571
+ // Provision the inactive role via the ops Lambda.
572
+ const password = randomBytes(24).toString('hex');
573
+ const provisioned = await rotate('provision', { baseRole: base, password });
574
+ success(` ${detected.inactiveRole} provisioned with new password.`);
575
+
576
+ // Build the new URL.
577
+ const newUrl = preserveConnParams(
578
+ `postgresql://${detected.inactiveRole}:${password}@${conn.host}:${conn.port}/${conn.database}`,
579
+ sourceUrl,
580
+ );
581
+
582
+ // Write to the SST secret store (blob-first per Fable ruling — must
583
+ // complete before UpdateFunctionConfiguration so a crash at any point
584
+ // is recovered by re-running rotate or by deploying).
585
+ step(` Updating secrets (${secretKeys.join(', ')})...`);
586
+ try {
587
+ const secrets = { ...stageSecrets };
588
+ for (const key of secretKeys) {
589
+ let credUrl = newUrl;
590
+ if (key === 'ReadDatabaseUrl') {
591
+ const existingReadUrl = stageSecrets.READ_DATABASE_URL ?? stageSecrets.ReadDatabaseUrl;
592
+ if (existingReadUrl) {
593
+ try {
594
+ const u = new URL(newUrl);
595
+ u.hostname = new URL(existingReadUrl).hostname;
596
+ credUrl = u.toString();
597
+ } catch { /* use primary host */ }
598
+ }
599
+ }
600
+ secrets[key] = credUrl;
601
+ credEnvOverrides[`EVERYSTACK_CRED_${key}`] = credUrl;
602
+ }
603
+ await putSstSecrets(secretsCtx, secrets);
604
+ stageSecrets = secrets;
605
+ success(` Secrets written.`);
606
+ } catch (err: any) {
607
+ fail(` Secret write failed: ${err.message}`);
608
+ info(' Ensure your IAM role has s3:PutObject + kms:Encrypt on the SST state bucket.');
609
+ process.exit(1);
610
+ }
611
+ }
612
+
613
+ // Rolling restart all linked functions with credential env overrides.
614
+ // Each function gets only the credentials it links — preserving the split.
615
+ // Track failures on credential-carrying functions — if ANY fail, abort
616
+ // before retire (both roles keep LOGIN = safe re-runnable state).
617
+ const { rollingRestart } = await import('../aws');
618
+ step('Rolling restart...');
619
+ const apiCreds: Record<string, string> = {};
620
+ const opsCreds: Record<string, string> = {};
621
+ for (const [k, v] of Object.entries(credEnvOverrides)) {
622
+ if (k.includes('Admin')) {
623
+ opsCreds[k] = v;
624
+ } else {
625
+ apiCreds[k] = v;
626
+ }
627
+ }
628
+ const allCreds = { ...apiCreds, ...opsCreds };
629
+ const functions: Array<{ name: string; creds: Record<string, string>; critical: boolean }> = [
630
+ { name: config.apiFunctionName, creds: apiCreds, critical: true },
631
+ ...(config.opsFunctionName ? [{ name: config.opsFunctionName, creds: allCreds, critical: true }] : []),
632
+ ...(config.workerFunctionName ? [{ name: config.workerFunctionName, creds: {}, critical: false }] : []),
633
+ ...(config.imageFunctionName ? [{ name: config.imageFunctionName, creds: {}, critical: false }] : []),
634
+ ];
635
+
636
+ let restartFailed = false;
637
+ for (const { name: fn, creds, critical } of functions) {
638
+ try {
639
+ const { newGen } = await rollingRestart(config.region, fn, creds);
640
+ success(` ${fn} → gen ${newGen}`);
641
+ } catch (err: any) {
642
+ if (critical) {
643
+ fail(` ${fn}: ${err.message}`);
644
+ restartFailed = true;
645
+ } else {
646
+ warn(` ${fn}: ${err.message}`);
647
+ }
648
+ }
649
+ }
650
+
651
+ if (restartFailed) {
652
+ fail('Restart failed on a credential-carrying function. Both roles keep LOGIN (safe state).');
653
+ info('Fix the issue and re-run db:provision --rotate. No credential was revoked.');
654
+ process.exit(1);
655
+ }
656
+
657
+ // Verify the new credential can log in — prove it works before revoking
658
+ // the old one. Runs inside the VPC via the ops Lambda.
659
+ step('Verifying new credential...');
660
+ const newApiUrl = credEnvOverrides.EVERYSTACK_CRED_DatabaseUrl;
661
+ if (newApiUrl) {
662
+ try {
663
+ await rotate('probe-login', { url: newApiUrl });
664
+ success('New credential connects to the database.');
665
+ } catch (err: any) {
666
+ fail(`New credential login failed: ${err.message}`);
667
+ info('Both roles keep LOGIN (safe state). Fix and re-run.');
668
+ process.exit(1);
669
+ }
670
+ }
671
+
672
+ // Origin HTTP check (non-blocking — the login probe is the real gate).
673
+ if (config.baseUrl) {
674
+ try {
675
+ const probe = `${config.baseUrl}?_probe=${Date.now()}`;
676
+ const res = await fetch(probe, { redirect: 'follow' });
677
+ if (res.ok) success('Origin responding.');
678
+ else warn(`Origin returned ${res.status}.`);
679
+ } catch (err: any) {
680
+ warn(`Origin probe failed: ${err.message}`);
681
+ }
682
+ }
683
+
684
+ // Retire old roles and drain sessions — using the roles recorded at
685
+ // detection time, NOT re-detected (F1 fix: both roles have LOGIN now,
686
+ // re-detection would pick the wrong one via the tiebreak).
687
+ for (const { base, oldRole } of oldActiveRoles) {
688
+ step(`Retiring ${oldRole}...`);
689
+ await rotate('retire', { role: oldRole });
690
+ success(` ${oldRole} set to NOLOGIN.`);
691
+
692
+ // Also retire the base role if it still has LOGIN (F6 fix).
693
+ try {
694
+ const baseDetect = await rotate('detect', { baseRole: base });
695
+ if (baseDetect === null) {
696
+ // Base role check — query directly via db:query
697
+ const baseResult = await invokeAction(config.region, opsFn, 'db:query', {
698
+ sql: `SELECT rolcanlogin FROM pg_roles WHERE rolname = '${base}'`,
699
+ });
700
+ const rows = (baseResult as any)?.rows ?? baseResult;
701
+ if (Array.isArray(rows) && rows[0]?.rolcanlogin) {
702
+ await rotate('retire', { role: base });
703
+ success(` ${base} (base role) set to NOLOGIN.`);
704
+ }
705
+ }
706
+ } catch { /* best-effort */ }
707
+
708
+ step(`Draining ${oldRole} sessions (grace: ${graceMs / 1000}s)...`);
709
+ const deadline = Date.now() + graceMs;
710
+ const POLL_MS = 2000;
711
+ while (Date.now() < deadline) {
712
+ const { count } = await rotate('count-sessions', { role: oldRole });
713
+ if (count === 0) { success(` ${oldRole} drained cleanly.`); break; }
714
+ await new Promise((r) => setTimeout(r, POLL_MS));
715
+ }
716
+ const { count: remaining } = await rotate('count-sessions', { role: oldRole });
717
+ if (remaining > 0) {
718
+ const { terminated } = await rotate('terminate-sessions', { role: oldRole });
719
+ success(` ${oldRole} drained (${terminated} session(s) terminated after grace period).`);
720
+ }
721
+ }
722
+
723
+ success('Credential rotation complete.');
724
+
725
+ } catch (err: any) {
726
+ if (err && (err as any).code !== 'EEXIT') {
727
+ fail(`Rotation error: ${err.message}`);
728
+ process.exit(1);
729
+ }
730
+ throw err;
731
+ }
732
+ }
733
+
474
734
  export async function dbProvisionCommand(flags: Record<string, string>): Promise<void> {
475
735
  if (!flags.stage) {
476
736
  fail('--stage is required for db:provision (it writes the stage\'s DATABASE_URL secret — the new credentials are never displayed, so they must land in the secret store)');
477
737
  process.exit(1);
478
738
  }
479
739
 
740
+ if (flags.rotate) {
741
+ await rotateCredentials(flags);
742
+ return;
743
+ }
744
+
480
745
  step('Resolving deployed config...');
481
746
  let config;
482
747
  try {
@@ -558,7 +823,7 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
558
823
  const { createUrlRunner } = await import('../db-source');
559
824
  const { runner, end } = await createUrlRunner(url);
560
825
  try {
561
- return await runProvision({ authPassword, adminPassword, searchPath }, {
826
+ return await runProvision({ authPassword, adminPassword, searchPath, rotationPairs: !!flags['rotation-pairs'] }, {
562
827
  execute: async (statement) => runner(statement),
563
828
  connection: parseUrlConnection(url),
564
829
  // Same probe as the ops-Lambda venue: a real login as the new role, DDL-capable.
@@ -591,7 +856,7 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
591
856
  conn = parseUrlConnection(directUrl);
592
857
  } else {
593
858
  try {
594
- result = await invokeAction(config.region, opsFunction(config), 'db:provision', { authPassword, adminPassword, searchPath });
859
+ result = await invokeAction(config.region, opsFunction(config), 'db:provision', { authPassword, adminPassword, searchPath, rotationPairs: !!flags['rotation-pairs'] });
595
860
  } catch (err: any) {
596
861
  // Auto-fallback: no Ops Lambda (Unknown action), but an ADMIN_DATABASE_URL secret exists —
597
862
  // provision directly over it. Master never crosses the CLI; the operator sets nothing new.
@@ -85,7 +85,7 @@ export async function diagCommand(
85
85
  cacheStatus: response.headers.get('x-cache') ?? undefined,
86
86
  cacheAge: response.headers.get('age') ?? undefined,
87
87
  cacheControl: response.headers.get('cache-control') ?? undefined,
88
- serverDebug: response.headers.get('x-server-debug') ?? undefined,
88
+ serverDebug: response.headers.get('x-everystack-release') ?? response.headers.get('x-everystack-created') ?? undefined,
89
89
  };
90
90
 
91
91
  // Fetch full HTML if hydration analysis requested
@@ -138,7 +138,7 @@ export async function diagCommand(
138
138
  if (diag.serverDebug) {
139
139
  console.log(` Serving Release: ${diag.serverDebug}`);
140
140
  } else {
141
- warn(' No X-Server-Debug header found. Update @everystack/server to get release timestamp on SSR responses.');
141
+ warn(' No x-everystack-release header found. Update @everystack/server (>= 0.4.22) to get release headers on SSR responses.');
142
142
  }
143
143
  console.log('');
144
144
 
@@ -168,15 +168,15 @@ export async function diagCommand(
168
168
  // 4. Version comparison
169
169
  if (!manifest) {
170
170
  warn(' Cannot compare: no manifest available.');
171
- } else if (diag.serverDebug && manifest.createdAt) {
172
- if (diag.serverDebug === manifest.createdAt) {
171
+ } else if (diag.serverDebug && manifest.updateId) {
172
+ if (diag.serverDebug === manifest.updateId) {
173
173
  success(' Version match: serving the latest release');
174
174
  } else {
175
- fail(` Version mismatch: serving ${diag.serverDebug}, latest is ${manifest.createdAt}`);
175
+ fail(` Version mismatch: serving ${diag.serverDebug}, latest is ${manifest.updateId}`);
176
176
  info(' Run `everystack cache:purge --origin web` to bust the CDN cache.');
177
177
  }
178
178
  } else {
179
- warn(' Cannot compare: missing X-Server-Debug header or manifest timestamp.');
179
+ warn(' Cannot compare: missing x-everystack-release header or manifest updateId.');
180
180
  }
181
181
 
182
182
  console.log('');
package/src/cli/index.ts CHANGED
@@ -353,7 +353,8 @@ Usage:
353
353
  everystack db:psql --stage <name> Interactive ADMIN psql (IAM-gated; resolves the admin URL in-process)
354
354
  everystack db:psql [--stage <name>] -c <command> Run one query via Lambda (works for private RDS)
355
355
  everystack db:doctor [--stage <name>] [--direct | --database-url <api> [--admin-database-url <ops>]] Check the DB is least-privilege + RLS-subject (api vs operator connection). No flag = ops-Lambda venue (auto-falls to direct via the stage secrets if the handler has no dbPlugin). --direct = probe both connections from the stage's DATABASE_URL + ADMIN_DATABASE_URL secrets; --database-url = explicit local venue (a lone URL is probed as both)
356
- everystack db:provision --stage <name> [--direct | --database-url <url>] Create the least-privilege role chain on an EXISTING database (idempotent; creates no DB). No flag = ops-Lambda venue (auto-falls to direct via the ADMIN_DATABASE_URL secret if the handler has no dbPlugin). --direct = direct connection reading that secret (master never on argv); --database-url = explicit URL. Declared schemas are set as the ROLE default (ALTER ROLE … SET search_path) — the secret stays credentials-only, and the URL is libpq-clean
356
+ everystack db:provision --stage <name> [--direct | --database-url <url>] [--rotation-pairs] Create the least-privilege role chain (idempotent). --rotation-pairs creates A/B pairs for live rotation
357
+ everystack db:provision --rotate --stage <name> [--slot api|ops|both] [--grace 60] Rotate credentials live (A/B role swap, no deploy, no downtime)
357
358
  everystack db:snapshot [--stage <name>] [--instance <id>] Take a physical RDS snapshot (instant DR point; RDS only — use db:backup for portable logical backups)
358
359
  everystack db:snapshots [--stage <name>] [--instance <id>] List manual RDS snapshots for the instance
359
360
  everystack db:backup [--stage <name>] Logical pg_dump of the DB → private S3 backups bucket (prints the backup id). Add --database-url <local> --stage <target> to dump a LOCAL database and upload it into the target stage's bucket as a restorable backup — the credential-free break-glass local→stage full replace (then: db:restore --from <id> --stage <target>)