@steve31415/baselib 3.3.0 → 3.4.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/README.md CHANGED
@@ -28,6 +28,10 @@ from a named `NAME@snewman.net` sender, no outbox — a failed send is an
28
28
  ERROR log (`~/plasticine-way/docs/SECURITY.md`, "Resend API key").
29
29
 
30
30
  Bins: `check-test-owners` — the fleet's structural test-coverage gate; every
31
- app runs it from `npm run verify`.
31
+ app runs it from `npm run verify`. `pw-deploy` / `pw-rollback` — the fleet
32
+ deploy (`~/plasticine-way/docs/OPERATIONS.md`, "Deployment safety").
33
+ `pw-sql` / `pw-logs` — read-only production queries (database, Axiom), the
34
+ first step of any production investigation (same doc, "Investigating
35
+ production state").
32
36
 
33
37
  Reflects Plasticine Way commit: see `docs/IMPL.md` footer.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ // `pw-logs` — Axiom log query (OPERATIONS.md "Investigating production
3
+ // state"). All logic lives in ../ops/logs.ts.
4
+ import { runLogs } from '../ops/logs.js';
5
+ process.exit(await runLogs({ argv: process.argv.slice(2), cwd: process.cwd() }));
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ // `pw-sql` — read-only production SQL (OPERATIONS.md "Investigating
3
+ // production state"). All logic lives in ../ops/sql.ts.
4
+ import { runSql } from '../ops/sql.js';
5
+ process.exit(await runSql({ argv: process.argv.slice(2), cwd: process.cwd() }));
package/dist/db.d.ts CHANGED
@@ -1,5 +1,17 @@
1
+ import type { ConnectorOptions } from '@google-cloud/cloud-sql-connector';
1
2
  import pg from 'pg';
2
3
  import { type Logger } from './log-core.js';
4
+ export declare const dateAsText: (v: string) => string;
5
+ /** Connection options for a Cloud SQL instance the fleet way: the Node
6
+ * connector, IAM authentication, public IP. `auth` replaces the connector's
7
+ * default credential resolution (the agent's tooling needs that; deployed
8
+ * code uses the default). The connector stays open for the connections'
9
+ * lifetime; close it when they are done. */
10
+ export declare function cloudSqlClientOptions(instance: string, auth?: ConnectorOptions['auth']): Promise<{
11
+ options: Awaited<ReturnType<Connector['getOptions']>>;
12
+ connector: Connector;
13
+ }>;
14
+ type Connector = import('@google-cloud/cloud-sql-connector').Connector;
3
15
  export interface PoolOptions {
4
16
  /** Override the database name (default: env DB_NAME / DATABASE_URL path). */
5
17
  database?: string;
@@ -32,3 +44,4 @@ export declare function endPool(pool: pg.Pool): Promise<void>;
32
44
  export declare function migrate(pool: pg.Pool, migrationsDir: string, logger?: Logger): Promise<{
33
45
  applied: string[];
34
46
  }>;
47
+ export {};
package/dist/db.js CHANGED
@@ -13,7 +13,23 @@ import { serializeError } from './log-core.js';
13
13
  // pg instance createPool actually uses — because apps that set it on their
14
14
  // own pg copy miss this one (file:-dependency installs resolve separate pg
15
15
  // instances).
16
- pg.types.setTypeParser(pg.types.builtins.DATE, (v) => v);
16
+ export const dateAsText = (v) => v;
17
+ pg.types.setTypeParser(pg.types.builtins.DATE, dateAsText);
18
+ /** Connection options for a Cloud SQL instance the fleet way: the Node
19
+ * connector, IAM authentication, public IP. `auth` replaces the connector's
20
+ * default credential resolution (the agent's tooling needs that; deployed
21
+ * code uses the default). The connector stays open for the connections'
22
+ * lifetime; close it when they are done. */
23
+ export async function cloudSqlClientOptions(instance, auth) {
24
+ const { Connector, AuthTypes, IpAddressTypes } = await import('@google-cloud/cloud-sql-connector');
25
+ const connector = new Connector(auth ? { auth } : {});
26
+ const options = await connector.getOptions({
27
+ instanceConnectionName: instance,
28
+ ipType: IpAddressTypes.PUBLIC,
29
+ authType: AuthTypes.IAM,
30
+ });
31
+ return { options, connector };
32
+ }
17
33
  /** pg-pool re-emits an idle client's socket error as its own 'error' event —
18
34
  * in production "Connection terminated unexpectedly" when Cloud SQL closes an
19
35
  * idle connection. Unhandled, that event throws and takes the process down
@@ -37,13 +53,7 @@ export async function createPool(opts = {}) {
37
53
  async function newPool(opts) {
38
54
  const instance = process.env.CLOUD_SQL_INSTANCE;
39
55
  if (instance) {
40
- const { Connector, AuthTypes, IpAddressTypes } = await import('@google-cloud/cloud-sql-connector');
41
- const connector = new Connector();
42
- const clientOpts = await connector.getOptions({
43
- instanceConnectionName: instance,
44
- ipType: IpAddressTypes.PUBLIC,
45
- authType: AuthTypes.IAM,
46
- });
56
+ const { options: clientOpts } = await cloudSqlClientOptions(instance);
47
57
  return new pg.Pool({
48
58
  ...clientOpts,
49
59
  user: requireEnv('DB_IAM_USER'),
@@ -1,5 +1,8 @@
1
1
  import type { DeployConfig, DeployHooks } from './types.js';
2
+ export declare const DEFAULT_PROJECT = "plasticine-prod";
2
3
  export declare const DEFAULT_ACCOUNT = "coding-agent@plasticine-prod.iam.gserviceaccount.com";
4
+ /** App and service names (also the database and log `app` values). */
5
+ export declare const NAME: RegExp;
3
6
  export declare function validateConfig(raw: unknown): DeployConfig;
4
7
  export declare function loadConfig(repoRoot: string): Promise<DeployConfig>;
5
8
  export declare function loadHooks(repoRoot: string, config: DeployConfig): Promise<DeployHooks>;
@@ -3,8 +3,10 @@
3
3
  import { readFile } from 'node:fs/promises';
4
4
  import { pathToFileURL } from 'node:url';
5
5
  import { resolve } from 'node:path';
6
- export const DEFAULT_ACCOUNT = 'coding-agent@plasticine-prod.iam.gserviceaccount.com';
7
- const NAME = /^[a-z][a-z0-9-]{1,30}$/;
6
+ export const DEFAULT_PROJECT = 'plasticine-prod';
7
+ export const DEFAULT_ACCOUNT = `coding-agent@${DEFAULT_PROJECT}.iam.gserviceaccount.com`;
8
+ /** App and service names (also the database and log `app` values). */
9
+ export const NAME = /^[a-z][a-z0-9-]{1,30}$/;
8
10
  const PREFIX = /^[a-z0-9][a-z0-9/_-]*\/$/;
9
11
  function fail(detail) {
10
12
  throw new Error(`deploy.config.json invalid: ${detail}`);
@@ -12,7 +12,7 @@ import { DEFAULT_ACCOUNT, loadConfig, loadHooks } from './config.js';
12
12
  import { must, mustJson } from './exec.js';
13
13
  import { openEvidence, PhaseTimer } from './evidence.js';
14
14
  import { acquireLock, clearHold, publishRetainedAssets, readHold, readReleaseRecord, releaseLock, verifyLock, writeReleaseRecord, } from './gcs.js';
15
- import { consistentBaseSha, servingStateOf, SHA40 } from './plan.js';
15
+ import { consistentBaseSha, readServingState, SHA40 } from './plan.js';
16
16
  const RELEASE_DEADLINE_MS = 25 * 60_000;
17
17
  const IMAGE_DIGEST = /@sha256:[0-9a-f]{64}$/;
18
18
  export async function runDeploy(options) {
@@ -87,12 +87,9 @@ export async function runDeploy(options) {
87
87
  }
88
88
  const serving = [];
89
89
  for (const service of config.services) {
90
- const described = await mustJson(runner, 'gcloud', [
91
- 'run', 'services', 'describe', service.name,
92
- ...gcloudBase, '--region', config.region, '--format=json',
93
- ]);
90
+ const { described, state } = await readServingState(runner, gcloudBase, config.region, service.name);
94
91
  await evidence.save(`${service.name}-before.json`, JSON.stringify(described, null, 2));
95
- serving.push(servingStateOf(service.name, described));
92
+ serving.push(state);
96
93
  }
97
94
  const base = consistentBaseSha(serving, sha);
98
95
  if (!base.ok)
@@ -1,4 +1,4 @@
1
- import type { ServingState } from './types.js';
1
+ import type { Runner, ServingState } from './types.js';
2
2
  export declare const SHA40: RegExp;
3
3
  /** Lock staleness is judged from the GCS object's server-side timeCreated,
4
4
  * never from anything the lock writer wrote (its clock may be wrong). */
@@ -41,8 +41,32 @@ export declare function pickRevisionForSha(revisions: unknown[], targetSha: stri
41
41
  revision: string;
42
42
  imageDigest: string;
43
43
  } | null;
44
- /** Extract serving state from a `gcloud run services describe --format=json`. */
45
- export declare function servingStateOf(service: string, described: unknown): ServingState;
44
+ /** The one revision carrying 100% of traffic in a `gcloud run services
45
+ * describe --format=json`, from *observed* traffic (`status.traffic`, never
46
+ * `spec.traffic`, which is only the desired state). */
47
+ export declare function servingRevisionName(service: string, described: unknown): string;
48
+ /**
49
+ * Extract serving state from a `gcloud run services describe --format=json`.
50
+ *
51
+ * The serving revision's BUILD_ID and image are read from the service
52
+ * template only when that revision IS the latest created one. When it is
53
+ * not — a previous release created a revision that never took traffic (a
54
+ * failed secret-access check, a crashed startup, an aborted deploy) — the
55
+ * template describes the *wrong* revision, so the caller must pass that
56
+ * revision's own `gcloud run revisions describe --format=json` as
57
+ * `servingRevision` (readServingState does). Lesson 2026-09-04, watchdog2:
58
+ * a re-run after a failed traffic switch read the new SHA out of the
59
+ * template, logged "already serving", wrote the release record, and left
60
+ * the old revision serving.
61
+ */
62
+ export declare function servingStateOf(service: string, described: unknown, servingRevision?: unknown): ServingState;
63
+ /** Describe a service and resolve its serving state, describing the serving
64
+ * revision too whenever the template no longer belongs to it. Returns the
65
+ * raw service description as well, for evidence. */
66
+ export declare function readServingState(runner: Runner, gcloudBase: string[], region: string, service: string): Promise<{
67
+ described: unknown;
68
+ state: ServingState;
69
+ }>;
46
70
  export declare function contentTypeFor(path: string): string;
47
71
  /** Parse release-record listing rows into {sha, at} entries. */
48
72
  export declare function releaseEntriesFrom(rows: {
@@ -1,6 +1,7 @@
1
1
  // Pure decision logic for pw-deploy/pw-rollback: everything here is
2
2
  // side-effect-free so the concurrency-sensitive choices (lock staleness,
3
3
  // rollback targeting, serving-state consistency) are unit-tested directly.
4
+ import { mustJson } from './exec.js';
4
5
  export const SHA40 = /^[0-9a-f]{40}$/;
5
6
  /** Lock staleness is judged from the GCS object's server-side timeCreated,
6
7
  * never from anything the lock writer wrote (its clock may be wrong). */
@@ -101,22 +102,71 @@ export function pickRevisionForSha(revisions, targetSha) {
101
102
  matches.sort((a, b) => b.created - a.created);
102
103
  return matches[0] ? { revision: matches[0].revision, imageDigest: matches[0].imageDigest } : null;
103
104
  }
104
- /** Extract serving state from a `gcloud run services describe --format=json`. */
105
- export function servingStateOf(service, described) {
105
+ /** The one revision carrying 100% of traffic in a `gcloud run services
106
+ * describe --format=json`, from *observed* traffic (`status.traffic`, never
107
+ * `spec.traffic`, which is only the desired state). */
108
+ export function servingRevisionName(service, described) {
106
109
  const d = described;
107
110
  const active = (d.status?.traffic ?? []).filter((t) => (t.percent ?? 0) === 100);
108
111
  if (active.length !== 1 || !active[0].revisionName) {
109
112
  throw new Error(`${service}: expected exactly one 100% traffic target`);
110
113
  }
111
- const env = d.spec?.template?.spec?.containers?.[0]?.env ?? [];
114
+ return active[0].revisionName;
115
+ }
116
+ /**
117
+ * Extract serving state from a `gcloud run services describe --format=json`.
118
+ *
119
+ * The serving revision's BUILD_ID and image are read from the service
120
+ * template only when that revision IS the latest created one. When it is
121
+ * not — a previous release created a revision that never took traffic (a
122
+ * failed secret-access check, a crashed startup, an aborted deploy) — the
123
+ * template describes the *wrong* revision, so the caller must pass that
124
+ * revision's own `gcloud run revisions describe --format=json` as
125
+ * `servingRevision` (readServingState does). Lesson 2026-09-04, watchdog2:
126
+ * a re-run after a failed traffic switch read the new SHA out of the
127
+ * template, logged "already serving", wrote the release record, and left
128
+ * the old revision serving.
129
+ */
130
+ export function servingStateOf(service, described, servingRevision) {
131
+ const d = described;
132
+ const revision = servingRevisionName(service, described);
133
+ const latest = d.status?.latestCreatedRevisionName;
134
+ let container;
135
+ if (latest === undefined || latest === revision) {
136
+ container = d.spec?.template?.spec?.containers?.[0];
137
+ }
138
+ else if (servingRevision !== undefined) {
139
+ container = servingRevision.spec?.containers?.[0];
140
+ }
141
+ else {
142
+ throw new Error(`${service}: traffic serves ${revision} but the latest revision is ${latest}; ` +
143
+ 'the serving revision must be described separately');
144
+ }
145
+ const env = container?.env ?? [];
112
146
  const buildSha = env.find((e) => e.name === 'BUILD_ID')?.value ?? null;
113
147
  return {
114
148
  service,
115
- revision: active[0].revisionName,
149
+ revision,
116
150
  buildSha: buildSha !== null && SHA40.test(buildSha) ? buildSha : null,
117
- imageDigest: d.spec?.template?.spec?.containers?.[0]?.image ?? null,
151
+ imageDigest: container?.image ?? null,
118
152
  };
119
153
  }
154
+ /** Describe a service and resolve its serving state, describing the serving
155
+ * revision too whenever the template no longer belongs to it. Returns the
156
+ * raw service description as well, for evidence. */
157
+ export async function readServingState(runner, gcloudBase, region, service) {
158
+ const described = await mustJson(runner, 'gcloud', [
159
+ 'run', 'services', 'describe', service, ...gcloudBase, '--region', region, '--format=json',
160
+ ]);
161
+ const revision = servingRevisionName(service, described);
162
+ const latest = described.status?.latestCreatedRevisionName;
163
+ const servingRevision = latest !== undefined && latest !== revision
164
+ ? await mustJson(runner, 'gcloud', [
165
+ 'run', 'revisions', 'describe', revision, ...gcloudBase, '--region', region, '--format=json',
166
+ ])
167
+ : undefined;
168
+ return { described, state: servingStateOf(service, described, servingRevision) };
169
+ }
120
170
  const CONTENT_TYPES = {
121
171
  '.js': 'text/javascript',
122
172
  '.mjs': 'text/javascript',
@@ -8,7 +8,7 @@ import { must, mustJson } from './exec.js';
8
8
  import { openEvidence, PhaseTimer } from './evidence.js';
9
9
  import { acquireLock, listReleaseRecords, placeHold, releaseLock } from './gcs.js';
10
10
  import { DEFAULT_ACCOUNT, loadConfig } from './config.js';
11
- import { pickRevisionForSha, releaseEntriesFrom, selectRollbackTarget, servingStateOf, SHA40, } from './plan.js';
11
+ import { pickRevisionForSha, releaseEntriesFrom, selectRollbackTarget, readServingState, SHA40, } from './plan.js';
12
12
  export async function runRollback(options) {
13
13
  const log = options.log ?? ((m) => console.error(`[pw-rollback] ${m}`));
14
14
  const runner = options.runner;
@@ -39,11 +39,8 @@ export async function runRollback(options) {
39
39
  timer.enter('target');
40
40
  const serving = [];
41
41
  for (const service of config.services) {
42
- const described = await mustJson(runner, 'gcloud', [
43
- 'run', 'services', 'describe', service.name,
44
- ...gcloudBase, '--region', config.region, '--format=json',
45
- ]);
46
- serving.push(servingStateOf(service.name, described));
42
+ const { state } = await readServingState(runner, gcloudBase, config.region, service.name);
43
+ serving.push(state);
47
44
  }
48
45
  const evidence = await openEvidence(config.app, requested ?? serving[0].buildSha ?? 'rollback');
49
46
  evidenceDir = evidence.dir;
@@ -1,4 +1,5 @@
1
- export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
1
+ export declare const LOG_LEVELS: readonly ["debug", "info", "warn", "error"];
2
+ export type LogLevel = (typeof LOG_LEVELS)[number];
2
3
  export interface LogEvent {
3
4
  _time: string;
4
5
  app: string;
package/dist/log-core.js CHANGED
@@ -9,6 +9,7 @@
9
9
  // logger turns those into stdout drop markers that watchdog2's daily
10
10
  // ship-failure check counts. makeEvent also guards the shape of `meta`: every
11
11
  // distinct key is an Axiom column (see "Key-shape guard" below).
12
+ export const LOG_LEVELS = ['debug', 'info', 'warn', 'error'];
12
13
  export function serializeError(err) {
13
14
  if (err instanceof Error) {
14
15
  const out = { name: err.name, message: err.message, stack: err.stack };
@@ -0,0 +1,4 @@
1
+ import type { DeployConfig } from '../deploy/types.js';
2
+ export declare function fileExists(path: string): Promise<boolean>;
3
+ /** The deploy config of the nearest enclosing app repo, or null outside one. */
4
+ export declare function findAppConfig(startDir: string): Promise<DeployConfig | null>;
@@ -0,0 +1,28 @@
1
+ // Which app is this? The production query tools take their defaults (the
2
+ // database name, the log `app` filter, the GCP project) from the
3
+ // deploy.config.json of the app repo they are run in, found by walking up
4
+ // from the working directory.
5
+ import { access } from 'node:fs/promises';
6
+ import { dirname, join } from 'node:path';
7
+ import { loadConfig } from '../deploy/config.js';
8
+ export async function fileExists(path) {
9
+ try {
10
+ await access(path);
11
+ return true;
12
+ }
13
+ catch {
14
+ return false;
15
+ }
16
+ }
17
+ /** The deploy config of the nearest enclosing app repo, or null outside one. */
18
+ export async function findAppConfig(startDir) {
19
+ let dir = startDir;
20
+ for (;;) {
21
+ if (await fileExists(join(dir, 'deploy.config.json')))
22
+ return loadConfig(dir);
23
+ const parent = dirname(dir);
24
+ if (parent === dir)
25
+ return null;
26
+ dir = parent;
27
+ }
28
+ }
@@ -0,0 +1,16 @@
1
+ import { parseArgs, type ParseArgsConfig } from 'node:util';
2
+ export interface CliIo {
3
+ stdout: (text: string) => void;
4
+ log: (message: string) => void;
5
+ }
6
+ export declare function cliIo(name: string, overrides?: Partial<CliIo>): CliIo;
7
+ type Options = NonNullable<ParseArgsConfig['options']>;
8
+ type Parsed<T extends Options> = ReturnType<typeof parseArgs<{
9
+ options: T;
10
+ allowPositionals: true;
11
+ }>>;
12
+ /** Parsed flags and positionals, or the exit code already dealt with. */
13
+ export declare function parseCli<T extends Options>(argv: string[], options: T, usage: string, io: CliIo): Parsed<T> | {
14
+ code: number;
15
+ };
16
+ export {};
@@ -0,0 +1,27 @@
1
+ // Shared command-line conventions of the pw-* query tools: parseArgs with
2
+ // positionals, usage + exit 2 on a bad flag, usage + exit 0 on --help,
3
+ // progress on stderr under the tool's name, results on stdout.
4
+ import { parseArgs } from 'node:util';
5
+ export function cliIo(name, overrides = {}) {
6
+ return {
7
+ stdout: overrides.stdout ?? ((t) => process.stdout.write(`${t}\n`)),
8
+ log: overrides.log ?? ((m) => console.error(`[${name}] ${m}`)),
9
+ };
10
+ }
11
+ /** Parsed flags and positionals, or the exit code already dealt with. */
12
+ export function parseCli(argv, options, usage, io) {
13
+ let parsed;
14
+ try {
15
+ parsed = parseArgs({ args: argv, options, allowPositionals: true });
16
+ }
17
+ catch (err) {
18
+ io.log(err.message);
19
+ io.stdout(usage);
20
+ return { code: 2 };
21
+ }
22
+ if (parsed.values.help) {
23
+ io.stdout(usage);
24
+ return { code: 0 };
25
+ }
26
+ return parsed;
27
+ }
@@ -0,0 +1,26 @@
1
+ export declare const isRecord: (v: unknown) => v is Record<string, unknown>;
2
+ /** Recursively drop null/undefined values and the objects left empty by
3
+ * that. Axiom's legacy query format returns every field of the dataset's
4
+ * union schema, almost all null; this turns a row back into what was
5
+ * actually logged. Arrays are kept whole (elements pruned in place). */
6
+ export declare function pruneNulls(value: unknown): unknown;
7
+ /** `{ meta: { user: 'x' } }` -> `{ 'meta.user': 'x' }`. Arrays and Dates are leaves. */
8
+ export declare function flatten(value: Record<string, unknown>, prefix?: string): Record<string, unknown>;
9
+ /** A value as text: strings as they are, Dates as UTC ISO, anything else as JSON. */
10
+ export declare function scalarText(value: unknown, nullAs?: string): string;
11
+ /** One table cell: NULL spelled out, line breaks escaped so a row stays one line. */
12
+ export declare function cell(value: unknown): string;
13
+ /** Aligned text table: header, rule, rows. Columns are left-aligned except
14
+ * numeric ones, which are right-aligned like psql. */
15
+ export declare function formatTable(columns: string[], rows: unknown[][]): string;
16
+ /** formatTable over records, one column per name. */
17
+ export declare function formatRecords(columns: string[], records: Record<string, unknown>[]): string;
18
+ /** `k=v` pairs for the fields of a flattened record, values JSON-quoted when
19
+ * they contain whitespace, quotes, or `=`. Greppable one-line form. */
20
+ export declare function pairs(record: Record<string, unknown>): string;
21
+ /** `30s`, `15m`, `6h`, `3d`, `1w` -> milliseconds; null when not a duration. */
22
+ export declare function parseDuration(text: string): number | null;
23
+ /** A time flag is either a duration counted back from `now` (`6h`) or an
24
+ * absolute timestamp (`2026-09-03T21:00Z`, `2026-09-03`, parsed as UTC when
25
+ * no zone is given). */
26
+ export declare function parseTimeFlag(text: string, now: Date): Date;
@@ -0,0 +1,104 @@
1
+ // Output helpers shared by the production query tools (pw-sql, pw-logs):
2
+ // null pruning, key flattening, aligned tables, and the time-flag grammar.
3
+ export const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v) && !(v instanceof Date);
4
+ /** Recursively drop null/undefined values and the objects left empty by
5
+ * that. Axiom's legacy query format returns every field of the dataset's
6
+ * union schema, almost all null; this turns a row back into what was
7
+ * actually logged. Arrays are kept whole (elements pruned in place). */
8
+ export function pruneNulls(value) {
9
+ if (Array.isArray(value))
10
+ return value.map(pruneNulls);
11
+ if (!isRecord(value))
12
+ return value;
13
+ const out = {};
14
+ for (const [key, raw] of Object.entries(value)) {
15
+ const v = pruneNulls(raw);
16
+ if (v === null || v === undefined)
17
+ continue;
18
+ if (isRecord(v) && Object.keys(v).length === 0)
19
+ continue;
20
+ out[key] = v;
21
+ }
22
+ return out;
23
+ }
24
+ /** `{ meta: { user: 'x' } }` -> `{ 'meta.user': 'x' }`. Arrays and Dates are leaves. */
25
+ export function flatten(value, prefix = '') {
26
+ const out = {};
27
+ for (const [key, v] of Object.entries(value)) {
28
+ const path = prefix ? `${prefix}.${key}` : key;
29
+ if (isRecord(v))
30
+ Object.assign(out, flatten(v, path));
31
+ else
32
+ out[path] = v;
33
+ }
34
+ return out;
35
+ }
36
+ /** A value as text: strings as they are, Dates as UTC ISO, anything else as JSON. */
37
+ export function scalarText(value, nullAs = '') {
38
+ if (value === null || value === undefined)
39
+ return nullAs;
40
+ if (value instanceof Date)
41
+ return isNaN(value.getTime()) ? 'Invalid Date' : value.toISOString();
42
+ if (typeof value === 'string')
43
+ return value;
44
+ return JSON.stringify(value);
45
+ }
46
+ /** One table cell: NULL spelled out, line breaks escaped so a row stays one line. */
47
+ export function cell(value) {
48
+ return scalarText(value, 'NULL').replace(/\r?\n/g, '\\n');
49
+ }
50
+ const NUMERIC = /^-?\d+(\.\d+)?$/;
51
+ /** Aligned text table: header, rule, rows. Columns are left-aligned except
52
+ * numeric ones, which are right-aligned like psql. */
53
+ export function formatTable(columns, rows) {
54
+ const widths = columns.map((c) => c.length);
55
+ const numeric = columns.map(() => rows.length > 0);
56
+ const text = rows.map((row) => columns.map((_, i) => {
57
+ const s = cell(row[i]);
58
+ widths[i] = Math.max(widths[i], s.length);
59
+ if (s !== 'NULL' && !NUMERIC.test(s))
60
+ numeric[i] = false;
61
+ return s;
62
+ }));
63
+ const line = (cells) => cells
64
+ .map((c, i) => (numeric[i] ? c.padStart(widths[i]) : c.padEnd(widths[i])))
65
+ .join(' ')
66
+ .trimEnd();
67
+ return [line(columns), widths.map((w) => '-'.repeat(w)).join(' '), ...text.map(line)].join('\n');
68
+ }
69
+ /** formatTable over records, one column per name. */
70
+ export function formatRecords(columns, records) {
71
+ return formatTable(columns, records.map((r) => columns.map((c) => r[c])));
72
+ }
73
+ /** `k=v` pairs for the fields of a flattened record, values JSON-quoted when
74
+ * they contain whitespace, quotes, or `=`. Greppable one-line form. */
75
+ export function pairs(record) {
76
+ return Object.entries(record)
77
+ .map(([k, v]) => {
78
+ const s = scalarText(v);
79
+ return `${k}=${/^[^\s"'=]+$/.test(s) ? s : JSON.stringify(s)}`;
80
+ })
81
+ .join(' ');
82
+ }
83
+ const DURATION = /^(\d+(?:\.\d+)?)\s*(s|m|h|d|w)$/;
84
+ const UNIT_MS = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 };
85
+ /** `30s`, `15m`, `6h`, `3d`, `1w` -> milliseconds; null when not a duration. */
86
+ export function parseDuration(text) {
87
+ const m = DURATION.exec(text.trim());
88
+ return m ? Math.round(Number(m[1]) * UNIT_MS[m[2]]) : null;
89
+ }
90
+ /** A time flag is either a duration counted back from `now` (`6h`) or an
91
+ * absolute timestamp (`2026-09-03T21:00Z`, `2026-09-03`, parsed as UTC when
92
+ * no zone is given). */
93
+ export function parseTimeFlag(text, now) {
94
+ const ms = parseDuration(text);
95
+ if (ms !== null)
96
+ return new Date(now.getTime() - ms);
97
+ const t = text.trim();
98
+ const hasZone = /(Z|[+-]\d{2}:?\d{2})$/i.test(t);
99
+ const parsed = new Date(/^\d{4}-\d{2}-\d{2}$/.test(t) || hasZone ? t : `${t}Z`);
100
+ if (isNaN(parsed.getTime())) {
101
+ throw new Error(`not a duration (30m, 6h, 3d) or timestamp (2026-09-03T21:00Z): ${text}`);
102
+ }
103
+ return parsed;
104
+ }
@@ -0,0 +1,25 @@
1
+ import { GoogleAuth, OAuth2Client } from 'google-auth-library';
2
+ import type { Runner } from '../deploy/types.js';
3
+ export type GcpCredential = {
4
+ kind: 'adc';
5
+ path: string;
6
+ } | {
7
+ kind: 'key-file';
8
+ path: string;
9
+ account: string;
10
+ } | {
11
+ kind: 'access-token';
12
+ token: string;
13
+ account: string;
14
+ };
15
+ /** Test seams; every field defaults to the real thing. */
16
+ export interface CredentialDeps {
17
+ env?: NodeJS.ProcessEnv;
18
+ home?: string;
19
+ exists?: (path: string) => Promise<boolean>;
20
+ runner?: Runner;
21
+ }
22
+ export declare function resolveGcpCredential(deps?: CredentialDeps): Promise<GcpCredential>;
23
+ /** Short human description for the tool's progress line. */
24
+ export declare function describeCredential(cred: GcpCredential): string;
25
+ export declare function authClientFor(cred: GcpCredential): GoogleAuth | OAuth2Client;
@@ -0,0 +1,64 @@
1
+ // Google credentials for the agent's own tooling (not deployed code). The
2
+ // containers have no application-default credentials file, only gcloud's
3
+ // stored account keys, so the connector's default resolution fails there
4
+ // (after probing for a metadata server); this resolves, in order: an
5
+ // explicit or well-known ADC file, gcloud's stored key for the active
6
+ // account, and finally a gcloud-minted access token. The result is what the
7
+ // Cloud SQL connector accepts as `auth`.
8
+ import { homedir } from 'node:os';
9
+ import { join } from 'node:path';
10
+ import { GoogleAuth, OAuth2Client } from 'google-auth-library';
11
+ import { must, realRunner } from '../deploy/exec.js';
12
+ import { fileExists } from './app.js';
13
+ // Both halves of an IAM Cloud SQL connection: the admin API for the
14
+ // ephemeral certificate and the login token itself.
15
+ const CLOUD_SQL_SCOPES = [
16
+ 'https://www.googleapis.com/auth/sqlservice.admin',
17
+ 'https://www.googleapis.com/auth/sqlservice.login',
18
+ ];
19
+ export async function resolveGcpCredential(deps = {}) {
20
+ const env = deps.env ?? process.env;
21
+ const exists = deps.exists ?? fileExists;
22
+ const runner = deps.runner ?? realRunner;
23
+ const explicit = env.GOOGLE_APPLICATION_CREDENTIALS;
24
+ if (explicit) {
25
+ if (!(await exists(explicit))) {
26
+ throw new Error(`GOOGLE_APPLICATION_CREDENTIALS points at a missing file: ${explicit}`);
27
+ }
28
+ return { kind: 'adc', path: explicit };
29
+ }
30
+ const gcloudDir = env.CLOUDSDK_CONFIG ?? join(deps.home ?? homedir(), '.config', 'gcloud');
31
+ const adc = join(gcloudDir, 'application_default_credentials.json');
32
+ if (await exists(adc))
33
+ return { kind: 'adc', path: adc };
34
+ const account = (await runner('gcloud', ['config', 'get', 'account'])).stdout.trim();
35
+ if (!account) {
36
+ throw new Error('no Google credentials: no application-default credentials file and no active gcloud account');
37
+ }
38
+ const keyFile = join(gcloudDir, 'legacy_credentials', account, 'adc.json');
39
+ if (await exists(keyFile))
40
+ return { kind: 'key-file', path: keyFile, account };
41
+ const token = (await must(runner, 'gcloud', ['auth', 'print-access-token'])).stdout.trim();
42
+ if (!token)
43
+ throw new Error('gcloud auth print-access-token returned nothing');
44
+ return { kind: 'access-token', token, account };
45
+ }
46
+ /** Short human description for the tool's progress line. */
47
+ export function describeCredential(cred) {
48
+ switch (cred.kind) {
49
+ case 'adc':
50
+ return `application-default credentials (${cred.path})`;
51
+ case 'key-file':
52
+ return `gcloud stored key for ${cred.account}`;
53
+ case 'access-token':
54
+ return `gcloud access token for ${cred.account}`;
55
+ }
56
+ }
57
+ export function authClientFor(cred) {
58
+ if (cred.kind === 'access-token') {
59
+ const client = new OAuth2Client();
60
+ client.setCredentials({ access_token: cred.token });
61
+ return client;
62
+ }
63
+ return new GoogleAuth({ keyFilename: cred.path, scopes: CLOUD_SQL_SCOPES });
64
+ }