@agentionai/fieldwork-cli 0.3.0 → 0.7.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
@@ -4,10 +4,10 @@
4
4
 
5
5
  ## Install
6
6
 
7
- Requires Node.js **22+** and npm. Version **0.3.0** is prepared for publication; until published, install the supplied archive:
7
+ Requires Node.js **22+** and npm. Version **0.7.0** is prepared for publication; until published, install the supplied archive:
8
8
 
9
9
  ```sh
10
- npm install --global ./agentionai-fieldwork-cli-0.3.0.tgz
10
+ npm install --global ./agentionai-fieldwork-cli-0.7.0.tgz
11
11
  fieldwork --version
12
12
  fieldwork --help
13
13
  ```
@@ -15,20 +15,23 @@ fieldwork --help
15
15
  After publication:
16
16
 
17
17
  ```sh
18
- npm install --global @agentionai/fieldwork-cli@0.3.0
18
+ npm install --global @agentionai/fieldwork-cli@0.7.0
19
19
  ```
20
20
 
21
- A checkout and pnpm are not required to use the installed CLI. npm resolves Commander, its only runtime dependency. This package includes neither the server nor the web app and installs no services. Connect to a separately provisioned compatible API; this early API has no authentication, so use a trusted local/private deployment. Installing a new client does not update server behavior.
21
+ Pin the version. The npm registry currently holds 0.3.0 and 0.4.0, which predate credentials and paged responses and cannot use the hosted service, so an unpinned install gets a client that fails against it.
22
+
23
+ A checkout and pnpm are not required to use the installed CLI. npm resolves Commander, its only runtime dependency. This package includes neither the server nor the web app and installs no services. By default it connects to the hosted service at `https://app.fieldworkledger.com`, which requires a credential; a local server is named with `--url` or `FIELDWORK_URL`. Installing a new client does not update server behavior.
22
24
 
23
25
  ## Quick start
24
26
 
25
- Use actual server and campaign references; the example campaign must already exist:
27
+ Issue an agent credential from the web app's Account page, then store it once for this machine. Use actual campaign references; the example campaign must already exist:
26
28
 
27
29
  ```sh
28
- fieldwork --url http://127.0.0.1:4310 campaigns list
30
+ fieldwork auth login --token TOKEN
31
+ fieldwork campaigns list
29
32
  mkdir memory-study
30
33
  cd memory-study
31
- fieldwork --url http://127.0.0.1:4310 setup campaign --campaign memory-study
34
+ fieldwork setup campaign --campaign memory-study
32
35
  fieldwork context
33
36
  fieldwork experiments create --json '{"name":"Baseline","hypothesis":"The candidate meets the agreed target"}'
34
37
  fieldwork runs create --experiment baseline --json '{"title":"Attempt 1"}'
@@ -41,21 +44,26 @@ Creating a missing campaign is explicit: `setup campaign --campaign new-study --
41
44
 
42
45
  Run `fieldwork <group> <command> --help` for flags and payload requirements.
43
46
 
44
- | Command | Operations |
45
- | --- | --- |
46
- | `products` | list, get, create, update, delete |
47
- | `campaigns` | list, get, context, create, update, delete |
48
- | `experiments`, `runs` | list, get, context, create, update, delete |
49
- | `schemas` | list, publish, default, set-default, get, template, validate |
50
- | `charts` | fields, list, create, get, data, delete |
47
+ | Command | Operations |
48
+ | ---------------- | ---------------------------------------------------------------------- |
49
+ | `products` | list, get, create, update, delete |
50
+ | `campaigns` | list, get, context, create, update, delete |
51
+ | `experiments` | list, get, context, create, update, delete |
52
+ | `runs` | list, get, context, create, record, update, delete |
53
+ | `artifacts` | list, get, create, update, delete, diff |
54
+ | `schemas` | list, publish, default, set-default, get, template, validate, extend |
55
+ | `charts` | fields, list, create, get, data, series, frontier, delete |
51
56
  | `setup campaign` | Attach a directory to a campaign; optionally create a missing campaign |
52
- | `context` | Effective server, campaign scope, and live inherited context |
57
+ | `context` | Effective server, campaign scope, and live inherited context |
58
+ | `auth` | login, status, logout: the credential stored for this server |
59
+ | `whoami` | The credential's organization and current permissions |
60
+ | `changelog` | Recent releases of this CLI, offline |
53
61
 
54
62
  - References accept stubs or UUIDs. Experiments/runs use campaign scope (`--campaign REF` or workspace); JSON `...Id` fields, schema versions, and charts require IDs.
55
63
  - Campaign list/create do not inherit product scope: pass `--product REF` when needed.
56
64
  - Schemas can belong to products, campaigns, or experiments. `schemas list --inherited` includes ancestors; defaults affect new records, not existing pins. Templates are illustrative, never measured evidence.
57
65
  - `schemas validate VERSION_ID --ready --json -` validates without saving. Invalid reports go to stdout, with `VALIDATION_FAILED` on stderr and exit 1; valid reports exit 0 even with missing-observation warnings. `--ready` checks required execution fields, not experiment variations.
58
- - Charts are immutable definitions over live typed successful-run data, with explicit exclusions and source run revisions. Discover fields before creation. No aggregation, expressions, or cross-version merging.
66
+ - Charts are immutable definitions over live typed successful-run data, with explicit exclusions and source run revisions. Discover fields before creation. Optional typed aggregation supports mean/sample stdev with mean±SD whiskers or line bands; no arbitrary expressions or cross-version merging. Two choices about a raw chart are saved with it and can change: `charts series CHART_ID` colours it by a typed field or by groups of runs you define, and a scatter chart's `charts frontier CHART_ID --json '{"runIds":[...]}'` saves the runs to join, chosen rather than computed.
59
67
  - Create/update read an object with `--json JSON` or stdin using `--json -`. Data results are JSON on stdout; errors are JSON on stderr with nonzero status. Help/version are text and work offline.
60
68
  - Updates require the current `revision`. Campaign/experiment/run deletes require `--revision N`; product/chart deletes do not. Re-read on conflicts, never blindly retry a create or increment stale revisions.
61
69
  - Only planned runs can be deleted. Parents with child records are protected; experiments with schemas referenced by campaign charts are also protected. Deleting a chart does not delete runs.
@@ -63,7 +71,7 @@ Run `fieldwork <group> <command> --help` for flags and payload requirements.
63
71
 
64
72
  ## Workspaces, server selection, and agent skill
65
73
 
66
- Server precedence is `--url`, `FIELDWORK_URL`, legacy `LAB_URL`, nearest workspace config, then `http://127.0.0.1:4310`. Use an HTTP(S) origin without credentials, resource paths, queries, or fragments. Product scoping is not authentication.
74
+ Server precedence is `--url`, `FIELDWORK_URL`, legacy `LAB_URL`, nearest workspace config, then `https://app.fieldworkledger.com`. A local server needs `--url http://127.0.0.1:4310` (or `FIELDWORK_URL`); a workspace set up against it remembers it. Credentials are stored per server, so a local token is never sent to the hosted service. Use an HTTP(S) origin without credentials, resource paths, queries, or fragments. Product scoping is not authentication.
67
75
 
68
76
  Setup creates `.fieldwork/workspace.json`, `README.md`, `fieldwork-skill.md`, an `AGENTS.md` reference (or appends to existing `agents.md`), and `docs/`, `assets/`, `logs/`, `results/`. Commands find the nearest binding upward from subdirectories. Setup refuses rebinding and preserves existing files. Legacy `.lab/workspace.json` remains supported. No files are uploaded or registered automatically.
69
77
 
@@ -77,3 +85,29 @@ npm root --global
77
85
  Compare and merge updates deliberately to preserve local instructions, or install the bundled file as `fieldwork/SKILL.md` in your agent's supported skill directory. The server's Docs page also provides copy/download, but reflects that server deployment's version.
78
86
 
79
87
  MCP, job orchestration, product queries/local search, managed Markdown recovery, artifact registration, and automatic per-run directory creation are not implemented. Local files and external URIs are data, not commands to execute; do not store secrets in records.
88
+
89
+ ### Schema extensions (0.4.0)
90
+
91
+ `schemas extend VERSION_ID --json JSON [--dry-run]` accepts a complete proposed definition, creates a compatible immutable successor, and atomically advances matching experiment/run/default/chart pins. New optional fields and enum values are allowed; breaking validation or unit/direction/comparison changes are rejected. Old definitions and creation snapshots remain intact. Reload incremented revisions. Preview before applying; a stale source cannot be extended again. Requires the corresponding updated API. Included in CLI 0.4.0; the API must be updated separately.
92
+
93
+ ### Recipes and artifacts (0.4.0)
94
+
95
+ `artifacts list --product REF`, `create --product REF --json`, `get/update/delete` with revision checks, and `diff FROM TO --product` are available in 0.4.0. Experiments and runs accept `artifacts: [stub-or-id]` references stored as immutable IDs; first reference permanently freezes the artifact, and changes require a `derivedFrom` successor with a new stub. Included in CLI 0.4.0; requires the updated API.
96
+
97
+ ### Selection, one-call recording and typed refs (0.5.0)
98
+
99
+ `runs list` and `experiments list` accept `--where FIELD=VALUE` (also `!=`, `>=`, `<=`, `>`, `<`; repeatable, combined with AND), `--fields PATHS`, `--format tsv`, and `runs list --experiment REF`. They select and project recorded values and compute nothing. **They now exclude superseded records (`extras.superseded_by`) and work under abandoned experiments by default**; pass `--include-superseded` or `--include-abandoned` to see them. This part works against any compatible API.
100
+
101
+ `runs record REF --json JSON` records observations and a status in one call, with the server reading the current revision, and `runs create` accepts `status`, `stub`, `startedAt`, `finishedAt` and `errorSummary`. Schema fields may have `type: ref` (optional `refKind`), validated against the product's artifact registry, linking and freezing the artifact and usable as a chart axis or `groupBy` key. Charts admit other versions of the same schema when every charted field keeps its value kind, unit and direction. These need the updated API.
102
+
103
+ ### Credentials (0.6.0)
104
+
105
+ `auth login --token TOKEN` verifies a credential against the server and stores it for that server only, under your config directory and owner-readable; `auth status` shows which credential is in use and `auth logout` removes it. Global `--token` and `--org` (or `FIELDWORK_TOKEN` and `FIELDWORK_ORGANIZATION`) override it per command; an agent credential is bound to one organization and needs no `--org`. `whoami` reports the credential's organization and current permissions. Agent credentials are issued from the web app's Account page. Requires an API with authentication.
106
+
107
+ ### Hosted default, paging, chart series and schema backfill (0.7.0)
108
+
109
+ The default server is the hosted service; a local one needs `--url` or `FIELDWORK_URL`. Lists and every stub lookup walk the API's paged `{ items, nextCursor }` responses, and `get`/`context` return child runs as a first page rather than an array. `charts series` and `charts frontier` save a raw chart's colouring and chosen frontier, and `schemas extend` accepts `backfill` to fill in fields a run predates on runs it re-pins, noted in `research.backfilled` and correctable with `runs update`. These need the updated API.
110
+
111
+ ### Release history
112
+
113
+ `fieldwork changelog [--since VERSION] [--release VERSION]` prints recent releases offline, marking per change whether it needs an updated API. The package carries recent releases only; the repository's `docs/cli-changelog.md` is the full history. A newer client never upgrades a server, and the CLI cannot verify what an API provides.
@@ -0,0 +1,137 @@
1
+ export const fullHistory = 'docs/cli-changelog.md in the Fieldwork repository';
2
+ export const entries = [
3
+ {
4
+ version: '0.7.0',
5
+ date: '2026-09-22',
6
+ status: 'prepared',
7
+ changes: [
8
+ {
9
+ summary: 'The default server is the hosted service, https://app.fieldworkledger.com, instead of http://127.0.0.1:4310. A local server needs --url or FIELDWORK_URL; a workspace set up against one keeps using it.',
10
+ requiresApi: false,
11
+ },
12
+ {
13
+ summary: 'Experiment and run lists, and every stub lookup behind get, record, update and delete, walk the paged { items, nextCursor } responses. 0.6.0 read the first page shape as an invalid collection against a current server.',
14
+ requiresApi: true,
15
+ },
16
+ {
17
+ summary: 'charts frontier CHART_ID --json {"runIds":[...]} saves the runs a scatter chart joins as its frontier, replacing any earlier choice. Chosen, never computed; only runs the chart plots are accepted.',
18
+ requiresApi: true,
19
+ },
20
+ {
21
+ summary: 'charts series CHART_ID --json {"series":...} colours a raw chart by a typed field, or by groups of runs you define (for a split no field records, such as ours against as shipped); null restores comparison-context series. charts create accepts series too.',
22
+ requiresApi: true,
23
+ },
24
+ {
25
+ summary: 'schemas extend accepts backfill: {field: {RUN_ID: value}} to fill in parameter or context fields a run predates -- added after it was recorded, by this extension or an earlier one -- finished runs included. Runs note them in research.backfilled, and a backfilled value can be corrected with runs update.',
26
+ requiresApi: true,
27
+ },
28
+ ],
29
+ },
30
+ {
31
+ version: '0.6.0',
32
+ date: '2026-09-20',
33
+ status: 'prepared',
34
+ changes: [
35
+ {
36
+ summary: 'fieldwork auth login, status and logout store one credential per server under your config directory, owner-readable only. Ordinary commands then need no --token. login verifies the credential before storing it, and status reports which source is in use.',
37
+ requiresApi: false,
38
+ },
39
+ {
40
+ summary: 'Global --token and --org send a credential and an organization with every request. An agent credential is bound to one organization, so --org is unnecessary for one.',
41
+ requiresApi: true,
42
+ },
43
+ {
44
+ summary: 'Authentication failures explain themselves: 401 reports a missing credential and 403 the wrong organization or a withdrawn grant, both stating that a retry will not help.',
45
+ requiresApi: true,
46
+ },
47
+ ],
48
+ },
49
+ {
50
+ version: '0.5.0',
51
+ date: '2026-09-18',
52
+ status: 'prepared',
53
+ changes: [
54
+ {
55
+ summary: 'runs list and experiments list select and project rows: --where FIELD=VALUE (also != >= <= > <, repeatable), --fields PATHS, --format tsv, and runs list --experiment REF. Selection and projection only; no values are computed.',
56
+ requiresApi: false,
57
+ },
58
+ {
59
+ summary: 'runs list and experiments list exclude superseded records (extras.superseded_by) and work under abandoned experiments by default; --include-superseded and --include-abandoned restore them. This changes the default output of an existing command.',
60
+ requiresApi: false,
61
+ },
62
+ {
63
+ summary: 'fieldwork changelog reports recent releases offline, marking which changes need an updated API.',
64
+ requiresApi: false,
65
+ },
66
+ {
67
+ summary: 'Network failures, timeouts and invalid server responses have distinct error codes and retry guidance; API failures retain the server request ID.',
68
+ requiresApi: false,
69
+ },
70
+ {
71
+ summary: 'runs record REF applies observations, extras, comparison context, artifacts, a status, an error summary or a logs URI in one call; the server reads the current revision, a result may be recorded directly from planned, and an identical revisionless retry reads back the committed result.',
72
+ requiresApi: true,
73
+ },
74
+ {
75
+ summary: 'runs create accepts status, stub, startedAt, finishedAt and errorSummary, so a finished or historical run is a single call. Dates are order-checked and never invented.',
76
+ requiresApi: true,
77
+ },
78
+ {
79
+ summary: 'Schema fields support type ref with optional refKind recipe or file, validated against the product artifact registry. A recorded ref links and freezes its artifact, and works as a bar X axis and groupBy key.',
80
+ requiresApi: true,
81
+ },
82
+ {
83
+ summary: 'Charts admit runs pinned to other versions of the same schema when every charted field keeps its value kind, unit and direction; schemaVersions "pinned" opts out, and charts data reports each version with its verdict.',
84
+ requiresApi: true,
85
+ },
86
+ {
87
+ summary: 'charts data leaves out runs marked extras.superseded_by, reporting each under excluded with the replacement named, so a pooled run can no longer be averaged in beside the per-set runs that replaced it and understate the spread.',
88
+ requiresApi: true,
89
+ },
90
+ {
91
+ summary: 'Experiment and run responses repeat schemaVersionId, varying, observations, comparisonContext, extras and artifactIds at the top level; research remains canonical.',
92
+ requiresApi: true,
93
+ },
94
+ ],
95
+ },
96
+ ];
97
+ function order(version) {
98
+ return version.split('.').map(Number);
99
+ }
100
+ export function compareVersions(a, b) {
101
+ const [left, right] = [order(a), order(b)];
102
+ for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
103
+ const difference = (left[index] ?? 0) - (right[index] ?? 0);
104
+ if (difference)
105
+ return difference;
106
+ }
107
+ return 0;
108
+ }
109
+ function invalid(message, hint) {
110
+ throw Object.assign(new Error(message), { code: 'UNKNOWN_VERSION', hint });
111
+ }
112
+ export function selectChangelog(cliVersion, options = {}) {
113
+ const oldest = entries[entries.length - 1].version;
114
+ for (const value of [options.since, options.release]) {
115
+ if (value !== undefined && !/^\d+(\.\d+)*$/.test(value))
116
+ invalid(`Not a version number: ${value}`, 'Use a dotted version such as 0.4.0.');
117
+ }
118
+ let selected = [...entries];
119
+ if (options.release) {
120
+ selected = selected.filter((entry) => entry.version === options.release);
121
+ if (!selected.length)
122
+ invalid(`Release ${options.release} is not bundled with this CLI`, `This package carries recent releases only, back to ${oldest}. See ${fullHistory}.`);
123
+ }
124
+ if (options.since)
125
+ selected = selected.filter((entry) => compareVersions(entry.version, options.since) > 0);
126
+ // An older --since must not read as "nothing else ever changed".
127
+ const truncated = !options.release && !!options.since && compareVersions(options.since, oldest) < 0;
128
+ return {
129
+ cliVersion,
130
+ recentOnly: true,
131
+ oldestBundled: oldest,
132
+ fullHistory,
133
+ ...(truncated ? { truncated: true } : {}),
134
+ note: `Recent releases only, back to ${oldest}${truncated ? `; changes before ${oldest} are not bundled` : ''}. requiresApi states what a change needs from the server, which this CLI cannot verify: a newer client does not upgrade an API.`,
135
+ entries: selected,
136
+ };
137
+ }
package/dist/client.js CHANGED
@@ -1,12 +1,85 @@
1
- export async function request(baseUrl, path, method = 'GET', body) {
2
- const response = await fetch(`${baseUrl.replace(/\/$/, '')}/api/v1${path}`, {
3
- method, headers: body === undefined ? {} : { 'Content-Type': 'application/json' },
4
- ...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(15000),
5
- });
1
+ const retryHint = (method) => method === 'GET'
2
+ ? 'This read is safe to retry.'
3
+ : 'The server may have committed this write. Inspect the target record before retrying.';
4
+ export async function request(baseUrl, path, method = 'GET', body, credentials = {}) {
5
+ const origin = baseUrl.replace(/\/$/, '');
6
+ let response;
7
+ try {
8
+ response = await fetch(`${origin}/api/v1${path}`, {
9
+ method,
10
+ headers: {
11
+ ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
12
+ ...(credentials.token ? { Authorization: `Bearer ${credentials.token}` } : {}),
13
+ ...(credentials.organization
14
+ ? { 'X-Fieldwork-Organization': credentials.organization }
15
+ : {}),
16
+ },
17
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
18
+ signal: AbortSignal.timeout(15000),
19
+ });
20
+ }
21
+ catch (error) {
22
+ const timedOut = error instanceof Error && ['AbortError', 'TimeoutError'].includes(error.name);
23
+ // Named, because the server is resolved from a flag, two environment variables, a
24
+ // workspace file or a default, and "could not reach the server" leaves the reader to
25
+ // guess which of those was in force. The origin carries no credential: tokens travel
26
+ // in a header.
27
+ throw Object.assign(new Error(timedOut
28
+ ? `Request to ${origin} timed out after 15 seconds`
29
+ : `Could not reach the server at ${origin}`), {
30
+ code: timedOut ? 'REQUEST_TIMEOUT' : 'NETWORK_ERROR',
31
+ hint: retryHint(method),
32
+ cause: error,
33
+ });
34
+ }
6
35
  if (response.status === 204)
7
36
  return { deleted: true };
8
- const data = await response.json();
37
+ let data;
38
+ try {
39
+ data = (await response.json());
40
+ }
41
+ catch (error) {
42
+ throw Object.assign(new Error(`Server returned an invalid response (HTTP ${response.status})`), {
43
+ code: 'INVALID_RESPONSE',
44
+ status: response.status,
45
+ hint: retryHint(method),
46
+ cause: error,
47
+ });
48
+ }
9
49
  if (!response.ok)
10
- throw Object.assign(new Error(data.message ?? `HTTP ${response.status}`), { code: data.code ?? 'HTTP_ERROR', status: response.status, ...(data.details ? { details: data.details } : {}) });
50
+ throw Object.assign(new Error(data.message ?? `HTTP ${response.status}`), {
51
+ code: data.code ?? 'HTTP_ERROR',
52
+ status: response.status,
53
+ ...(response.status === 401 || response.status === 403
54
+ ? {
55
+ hint: response.status === 401
56
+ ? 'Supply a credential with --token, FIELDWORK_TOKEN, or workspace configuration. Retrying without one will not succeed.'
57
+ : 'This credential is valid but not permitted here. Check the selected organization with --org or FIELDWORK_ORGANIZATION, and that its access has not been revoked.',
58
+ }
59
+ : {}),
60
+ ...(data.details ? { details: data.details } : {}),
61
+ ...(data.requestId ? { requestId: data.requestId } : {}),
62
+ });
11
63
  return data;
12
64
  }
65
+ /** Every page of a paged collection.
66
+ *
67
+ * The CLI filters, groups and formats over whole collections -- `--where`, superseded and
68
+ * abandoned exclusions, field selection -- so a page of them would apply each filter to a
69
+ * page and call it the answer. Walking is the honest reading of the same request; when a
70
+ * campaign is large enough for that to hurt, the filters belong in the query.
71
+ */
72
+ export async function requestAll(baseUrl, path, credentials = {}) {
73
+ const all = [];
74
+ let cursor = null;
75
+ do {
76
+ const separator = path.includes('?') ? '&' : '?';
77
+ const page = (await request(baseUrl, cursor ? `${path}${separator}cursor=${encodeURIComponent(cursor)}` : path, 'GET', undefined, credentials));
78
+ // A collection that is not paged answers with an array, and is its own only page.
79
+ if (Array.isArray(page))
80
+ return page;
81
+ all.push(...(page.items ?? []));
82
+ cursor = page.nextCursor ?? null;
83
+ } while (cursor);
84
+ return all;
85
+ }
@@ -0,0 +1,78 @@
1
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync, rmSync, existsSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ export function credentialsPath() {
5
+ const base = process.env['XDG_CONFIG_HOME'] || join(homedir(), '.config');
6
+ return join(base, 'fieldwork', 'credentials.json');
7
+ }
8
+ /** Origins are compared without a trailing slash so http://host and http://host/ agree. */
9
+ const key = (url) => url.replace(/\/$/, '');
10
+ function read() {
11
+ const path = credentialsPath();
12
+ if (!existsSync(path))
13
+ return { version: 1, servers: {} };
14
+ try {
15
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
16
+ return parsed?.servers && typeof parsed.servers === 'object'
17
+ ? { version: 1, servers: parsed.servers }
18
+ : { version: 1, servers: {} };
19
+ }
20
+ catch {
21
+ // A corrupted file must not lock the CLI out: an explicit --token or FIELDWORK_TOKEN
22
+ // still works, and `auth login` overwrites it.
23
+ return { version: 1, servers: {} };
24
+ }
25
+ }
26
+ export function storedCredential(url) {
27
+ return read().servers[key(url)];
28
+ }
29
+ export function storeCredential(url, credential) {
30
+ const path = credentialsPath();
31
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
32
+ const file = read();
33
+ file.servers[key(url)] = credential;
34
+ // Written before chmod, so the window where it exists with default permissions is as
35
+ // small as possible; the directory is already owner-only.
36
+ writeFileSync(path, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 });
37
+ chmodSync(path, 0o600);
38
+ return path;
39
+ }
40
+ export function forgetCredential(url) {
41
+ const file = read();
42
+ if (!file.servers[key(url)])
43
+ return false;
44
+ delete file.servers[key(url)];
45
+ const path = credentialsPath();
46
+ if (Object.keys(file.servers).length === 0)
47
+ rmSync(path, { force: true });
48
+ else {
49
+ writeFileSync(path, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 });
50
+ chmodSync(path, 0o600);
51
+ }
52
+ return true;
53
+ }
54
+ /** Resolution order, matching how --url resolves: explicit, then environment, then stored.
55
+ * The source is reported so `auth status` can say which one is in play -- the usual
56
+ * confusion is a stale environment variable shadowing a fresh login. */
57
+ export function resolveCredential(url, flags) {
58
+ if (flags.token)
59
+ return { token: flags.token, organization: flags.org, source: 'flag' };
60
+ const fromEnvironment = process.env['FIELDWORK_TOKEN'];
61
+ if (fromEnvironment)
62
+ return {
63
+ token: fromEnvironment,
64
+ organization: flags.org ?? process.env['FIELDWORK_ORGANIZATION'],
65
+ source: 'environment',
66
+ };
67
+ const stored = storedCredential(url);
68
+ if (stored)
69
+ return {
70
+ token: stored.token,
71
+ organization: flags.org ?? process.env['FIELDWORK_ORGANIZATION'] ?? stored.organization,
72
+ source: 'file',
73
+ };
74
+ return {
75
+ organization: flags.org ?? process.env['FIELDWORK_ORGANIZATION'],
76
+ source: 'none',
77
+ };
78
+ }