@junoflow/cli 0.11.0 → 0.12.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
@@ -185,6 +185,14 @@ The group's working-copy commands (`integration init`, `gen-types`,
185
185
  | `juno credential list` | `list_credentials` | — | read |
186
186
  | `juno credential set <id>` | none | credential id | act |
187
187
 
188
+ **`person`**
189
+
190
+ | Command | Tool | Positional | Effect |
191
+ | ------------------- | -------------- | ---------- | ------ |
192
+ | `juno person list` | `list_people` | — | read |
193
+ | `juno person get` | `get_person` | `id` | read |
194
+ | `juno person write` | `write_person` | `id` | change |
195
+
188
196
  **`ha`** (Home Assistant)
189
197
 
190
198
  | Command | Tool | Positional | Effect |
@@ -518,6 +526,29 @@ keeps them in `package.json` alone, so there `pull` leaves `dependencies` as it
518
526
  is. It refuses to overwrite uncommitted changes in a git working tree unless you
519
527
  pass `--force`.
520
528
 
529
+ ### The source, and the warning
530
+
531
+ Every kind's `check` and `deploy` send where the working copy sits in git: the
532
+ `origin` remote (the first remote without one; any user, password, query or
533
+ fragment stripped), the `HEAD` sha, and `dirty` when `git status` reports a
534
+ change or an untracked file under the working copy's directory. Outside git, or
535
+ before the first commit, they send nothing and deploy as usual. Juno records it
536
+ on the version, which is from then on **remote**: its source of truth is this
537
+ working copy, not Juno.
538
+
539
+ When a deploy moves the artifact's source, or replaces a version written in Juno
540
+ since your last pull, the instance answers one sentence and the CLI prints it on
541
+ stderr:
542
+
543
+ ```text
544
+ warning: flow "porch-light" was local; v8 is remote from now on — change it in the working copy and deploy.
545
+ ```
546
+
547
+ It is never a failure: the deploy has happened, and the exit code is what it
548
+ would have been. `check` prints the warning the deploy would. With `--json` the
549
+ sentence is the body's `source_warning`. See
550
+ [Where the Source Lives](https://boivie.github.io/juno/assistant/source/).
551
+
521
552
  ## Flows as files
522
553
 
523
554
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junoflow/cli",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Develop Juno flows, integrations and apps, and operate a Juno instance from the command line",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -40,7 +40,7 @@
40
40
  "node": ">=20"
41
41
  },
42
42
  "dependencies": {
43
- "@junoflow/eslint-plugin": "0.11.0",
43
+ "@junoflow/eslint-plugin": "0.12.0",
44
44
  "@typescript-eslint/parser": "^8.0.0"
45
45
  },
46
46
  "devDependencies": {
package/src/api.js CHANGED
@@ -87,9 +87,10 @@ export class JunoClient {
87
87
  *
88
88
  * It does not go through #workingCopyJSON, whose 422-without-diagnostics
89
89
  * wording differs from what this route has always printed; only the 409 is
90
- * shared.
90
+ * shared. `origin` (gitOrigin) is sent when given, dry runs included: they
91
+ * stamp nothing, but their source_warning says what a deploy would.
91
92
  */
92
- async ingest(project, { dryRun = false, id, baseVersion } = {}) {
93
+ async ingest(project, { dryRun = false, id, baseVersion, origin } = {}) {
93
94
  const path = `/api/integrations/ingest${dryRun ? '?dry_run=true' : ''}`;
94
95
  const res = await this.#request('POST', path, {
95
96
  manifest: project.manifest,
@@ -98,6 +99,7 @@ export class JunoClient {
98
99
  lockfile: project.lockfile ?? undefined,
99
100
  id: id ?? undefined,
100
101
  base_version: baseVersion ?? undefined,
102
+ origin: origin ?? undefined,
101
103
  });
102
104
  if (res.status === 409) throw new ConflictError(problemDetail(res));
103
105
  return this.#answer('POST', path, res);
@@ -187,16 +189,20 @@ export class JunoClient {
187
189
  * POST /api/flows/ingest — verify a flow working copy and, unless dryRun,
188
190
  * commit and activate it. `files` is the working copy's src/ tree, path →
189
191
  * content. `baseVersion` undefined sends none (a new flow,
190
- * or --force). A 409 is a ConflictError; a 422 with diagnostics is a
191
- * DiagnosticsError.
192
+ * or --force). `origin` (gitOrigin) is sent when given. A 409 is a
193
+ * ConflictError; a 422 with diagnostics is a DiagnosticsError.
192
194
  */
193
- async ingestFlow({ flowId, manifest, files, baseVersion }, { dryRun } = {}) {
195
+ async ingestFlow(
196
+ { flowId, manifest, files, baseVersion },
197
+ { dryRun, origin } = {}
198
+ ) {
194
199
  const query = dryRun ? '?dry_run=true' : '';
195
200
  return this.#workingCopyJSON(`/api/flows/ingest${query}`, {
196
201
  flow_id: flowId,
197
202
  manifest,
198
203
  files,
199
204
  base_version: baseVersion ?? undefined,
205
+ origin: origin ?? undefined,
200
206
  });
201
207
  }
202
208
 
@@ -227,11 +233,12 @@ export class JunoClient {
227
233
  * `baseVersion` undefined sends none (a copy that records no base, or
228
234
  * --force). When activating, a 409 is a ConflictError: the app's active
229
235
  * version is not the copy's base. A draft is never refused; the answer's
230
- * `stale_base` carries the sentence instead.
236
+ * `stale_base` carries the sentence instead. `origin` (gitOrigin) is sent
237
+ * when given.
231
238
  */
232
239
  async ingestApp(
233
240
  { manifest, files, lockfile },
234
- { activate = true, baseVersion } = {}
241
+ { activate = true, baseVersion, origin } = {}
235
242
  ) {
236
243
  return this.#workingCopyJSON('/api/apps/ingest', {
237
244
  manifest,
@@ -239,6 +246,7 @@ export class JunoClient {
239
246
  lockfile: lockfile ?? undefined,
240
247
  activate,
241
248
  base_version: baseVersion ?? undefined,
249
+ origin: origin ?? undefined,
242
250
  });
243
251
  }
244
252
 
@@ -1,10 +1,15 @@
1
1
  import { connect } from '../api.js';
2
2
  import { readAppProject } from '../app-project.js';
3
- import { printDiagnosticsJSON, printJSON } from '../diagnostics.js';
3
+ import {
4
+ printDiagnosticsJSON,
5
+ printJSON,
6
+ printSourceWarning,
7
+ } from '../diagnostics.js';
4
8
  import { DiagnosticsError } from '../errors.js';
5
9
  import { EXIT_CONFLICT } from '../exit.js';
6
10
  import { runLocalChecks } from './check.js';
7
11
  import { warnOnDependencyDrift } from './flow-check.js';
12
+ import { gitOrigin } from '../working-copy.js';
8
13
 
9
14
  export const APP_CHECK_USAGE = 'juno app check [--skip-local] [--json]';
10
15
 
@@ -35,6 +40,7 @@ export async function appCheck(argv, io) {
35
40
  result = await client.ingestApp(project, {
36
41
  activate: false,
37
42
  baseVersion: project.baseVersion,
43
+ origin: await gitOrigin(io.cwd, io.env),
38
44
  });
39
45
  } catch (err) {
40
46
  if (argv.json && err instanceof DiagnosticsError) {
@@ -47,6 +53,7 @@ export async function appCheck(argv, io) {
47
53
  printJSON(result, io);
48
54
  } else {
49
55
  for (const w of result.warnings ?? []) io.warn(`warning: ${w}`);
56
+ printSourceWarning(result, io);
50
57
  io.log(
51
58
  `${result.id}: verified and stored as draft v${result.version} on ${target}`
52
59
  );
@@ -8,10 +8,15 @@ import {
8
8
  SRC_DIR,
9
9
  updateAppPackage,
10
10
  } from '../app-project.js';
11
- import { printDiagnosticsJSON, printJSON } from '../diagnostics.js';
11
+ import {
12
+ printDiagnosticsJSON,
13
+ printJSON,
14
+ printSourceWarning,
15
+ } from '../diagnostics.js';
12
16
  import { DiagnosticsError } from '../errors.js';
13
17
  import { deployOnChange } from '../watch.js';
14
18
  import {
19
+ gitOrigin,
15
20
  MANIFEST,
16
21
  manifestText,
17
22
  typeFiles,
@@ -79,6 +84,7 @@ export async function appDeploy(argv, io) {
79
84
  async function deployProject(client, project, argv, io, target) {
80
85
  const result = await client.ingestApp(project, {
81
86
  baseVersion: argv.force ? undefined : project.baseVersion,
87
+ origin: await gitOrigin(io.cwd, io.env),
82
88
  });
83
89
 
84
90
  const manifestRaw = manifestText(result.manifest);
@@ -97,6 +103,7 @@ async function deployProject(client, project, argv, io, target) {
97
103
  printJSON(result, io);
98
104
  } else {
99
105
  for (const w of result.warnings ?? []) io.warn(`warning: ${w}`);
106
+ printSourceWarning(result, io);
100
107
  const files = written.length ? ` (${written.sort().join(', ')})` : '';
101
108
  io.log(`deployed ${result.id} v${result.version} to ${target}${files}`);
102
109
  if (result.url) io.log(`open: ${result.url}`);
@@ -2,11 +2,16 @@ import { spawn } from 'node:child_process';
2
2
  import { join } from 'node:path';
3
3
 
4
4
  import { connect } from '../api.js';
5
- import { printDiagnosticsJSON, printJSON } from '../diagnostics.js';
5
+ import {
6
+ printDiagnosticsJSON,
7
+ printJSON,
8
+ printSourceWarning,
9
+ } from '../diagnostics.js';
6
10
  import { DiagnosticsError, UserError } from '../errors.js';
7
11
  import { readProject } from '../project.js';
8
12
  import { baseFor } from './deploy.js';
9
13
  import { verifyTypes } from './verify-types.js';
14
+ import { gitOrigin } from '../working-copy.js';
10
15
 
11
16
  export const CHECK_USAGE =
12
17
  'juno integration check [--skip-local] [--id <id>] [--json]';
@@ -48,6 +53,7 @@ export async function check(argv, io) {
48
53
  dryRun: true,
49
54
  id: argv.id,
50
55
  baseVersion: baseFor(project, argv, io),
56
+ origin: await gitOrigin(io.cwd, io.env),
51
57
  });
52
58
  } catch (err) {
53
59
  if (argv.json && err instanceof DiagnosticsError) {
@@ -60,6 +66,7 @@ export async function check(argv, io) {
60
66
  printJSON(result, io);
61
67
  } else {
62
68
  for (const w of result.warnings ?? []) io.warn(`warning: ${w}`);
69
+ printSourceWarning(result, io);
63
70
  io.log(`${result.id}: manifest and sources verified against ${target}`);
64
71
  }
65
72
 
@@ -1,7 +1,11 @@
1
1
  import { join } from 'node:path';
2
2
 
3
3
  import { connect } from '../api.js';
4
- import { printDiagnosticsJSON, printJSON } from '../diagnostics.js';
4
+ import {
5
+ printDiagnosticsJSON,
6
+ printJSON,
7
+ printSourceWarning,
8
+ } from '../diagnostics.js';
5
9
  import { DiagnosticsError } from '../errors.js';
6
10
  import {
7
11
  readManifestRaw,
@@ -12,6 +16,7 @@ import {
12
16
  import { stampTypeFiles } from '../stamp.js';
13
17
  import { pickTypeFiles } from './gen-types.js';
14
18
  import { deployOnChange } from '../watch.js';
19
+ import { gitOrigin } from '../working-copy.js';
15
20
 
16
21
  export const DEPLOY_USAGE =
17
22
  'juno integration deploy [--watch] [--force] [--id <id>] [--json]';
@@ -57,6 +62,7 @@ async function deployOnce(client, argv, io, target) {
57
62
  const result = await client.ingest(project, {
58
63
  id: argv.id,
59
64
  baseVersion: argv.force ? undefined : baseFor(project, argv, io),
65
+ origin: await gitOrigin(io.cwd, io.env),
60
66
  });
61
67
  // Stamped against the manifest this deploy was built from, so the watch loop
62
68
  // leaves a tree that `juno integration verify-types` accepts.
@@ -82,6 +88,7 @@ async function deployOnce(client, argv, io, target) {
82
88
  return result;
83
89
  }
84
90
  for (const warning of result.warnings ?? []) io.warn(`warning: ${warning}`);
91
+ printSourceWarning(result, io);
85
92
  io.log(
86
93
  `deployed ${result.id} v${result.version} to ${target} (${written.sort().join(', ')})`
87
94
  );
@@ -2,7 +2,12 @@ import { connect } from '../api.js';
2
2
  import { DiagnosticsError } from '../errors.js';
3
3
  import { readFlowProject } from '../flow-project.js';
4
4
  import { runLocalChecks } from './check.js';
5
- import { printDiagnosticsJSON, printJSON } from '../diagnostics.js';
5
+ import {
6
+ printDiagnosticsJSON,
7
+ printJSON,
8
+ printSourceWarning,
9
+ } from '../diagnostics.js';
10
+ import { gitOrigin } from '../working-copy.js';
6
11
  import { printWarnings } from './flow-deploy.js';
7
12
 
8
13
  export const FLOW_CHECK_USAGE = 'juno flow check [--skip-local] [--json]';
@@ -28,7 +33,7 @@ export async function flowCheck(argv, io) {
28
33
  files: project.files,
29
34
  baseVersion: project.baseVersion,
30
35
  },
31
- { dryRun: true }
36
+ { dryRun: true, origin: await gitOrigin(io.cwd, io.env) }
32
37
  );
33
38
  } catch (err) {
34
39
  if (argv.json && err instanceof DiagnosticsError) {
@@ -41,6 +46,7 @@ export async function flowCheck(argv, io) {
41
46
  printJSON(result, io);
42
47
  } else {
43
48
  printWarnings(result.warnings, io);
49
+ printSourceWarning(result, io);
44
50
  io.log(`${result.flow_id}: manifest and src/ verified against ${target}`);
45
51
  }
46
52
  return failed ? 1 : 0;
@@ -6,6 +6,7 @@ import {
6
6
  formatDiagnostic,
7
7
  printDiagnosticsJSON,
8
8
  printJSON,
9
+ printSourceWarning,
9
10
  } from '../diagnostics.js';
10
11
  import {
11
12
  MANIFEST,
@@ -17,6 +18,7 @@ import {
17
18
  writeFlowFiles,
18
19
  } from '../flow-project.js';
19
20
  import { deployOnChange } from '../watch.js';
21
+ import { gitOrigin } from '../working-copy.js';
20
22
 
21
23
  export const FLOW_DEPLOY_USAGE =
22
24
  'juno flow deploy [--watch] [--force] [--json]';
@@ -73,12 +75,15 @@ async function deployOnce(client, argv, io, target) {
73
75
  }
74
76
 
75
77
  async function deployProject(client, project, argv, io, target) {
76
- const result = await client.ingestFlow({
77
- flowId: project.flowId,
78
- manifest: project.manifest,
79
- files: project.files,
80
- baseVersion: argv.force ? undefined : project.baseVersion,
81
- });
78
+ const result = await client.ingestFlow(
79
+ {
80
+ flowId: project.flowId,
81
+ manifest: project.manifest,
82
+ files: project.files,
83
+ baseVersion: argv.force ? undefined : project.baseVersion,
84
+ },
85
+ { origin: await gitOrigin(io.cwd, io.env) }
86
+ );
82
87
 
83
88
  const manifestRaw = manifestText(result.manifest);
84
89
  const written = await writeFlowFiles(io.cwd, {
@@ -96,6 +101,7 @@ async function deployProject(client, project, argv, io, target) {
96
101
  printJSON(result, io);
97
102
  } else {
98
103
  printWarnings(result.warnings, io);
104
+ printSourceWarning(result, io);
99
105
  const files = written.length ? ` (${written.sort().join(', ')})` : '';
100
106
  io.log(
101
107
  `deployed ${result.flow_id} v${result.version} to ${target}${files}`
@@ -28,3 +28,13 @@ export function printDiagnosticsJSON(err, io) {
28
28
  printJSON({ detail: err.message, diagnostics: err.diagnostics }, io);
29
29
  return 1;
30
30
  }
31
+
32
+ /**
33
+ * A route's `source_warning` — the server's sentence for a deploy that moves
34
+ * an artifact between sources (local, installed, remote) or supersedes a
35
+ * version written in Juno — on stderr as `warning: …`. Never a failure: the
36
+ * write already happened, or a check says what a deploy would do.
37
+ */
38
+ export function printSourceWarning(result, io) {
39
+ if (result?.source_warning) io.warn(`warning: ${result.source_warning}`);
40
+ }
@@ -249,6 +249,66 @@ export async function dirtyPaths(cwd) {
249
249
  }
250
250
  }
251
251
 
252
+ /**
253
+ * gitOrigin is where this working copy sits in git, as the `origin` every
254
+ * ingest route accepts: `{git: {remote, sha, dirty}}` — the origin remote's
255
+ * URL (the first remote when there is no `origin`, none when there is no
256
+ * remote), HEAD's sha, and whether `git status --porcelain` reports anything
257
+ * under this directory, untracked files included. Scoped to the working copy,
258
+ * so changes elsewhere in a monorepo do not make it dirty.
259
+ *
260
+ * Best effort and never fatal: not a repository, no commit yet, no git on
261
+ * PATH or any other failure answers undefined, and the deploy goes ahead
262
+ * without it. The remote loses any credentials here; the server strips them
263
+ * again rather than trusting this.
264
+ */
265
+ export async function gitOrigin(cwd, env) {
266
+ const git = async (...args) =>
267
+ (await execFileAsync('git', args, { cwd, env })).stdout;
268
+ try {
269
+ const sha = (await git('rev-parse', 'HEAD')).trim();
270
+ const dirty = (await git('status', '--porcelain', '--', '.')).trim() !== '';
271
+ let remote = '';
272
+ try {
273
+ remote = (await git('remote', 'get-url', 'origin')).trim();
274
+ } catch {
275
+ const first = (await git('remote')).split('\n')[0].trim();
276
+ if (first) remote = (await git('remote', 'get-url', first)).trim();
277
+ }
278
+ remote = stripCredentials(remote);
279
+ return {
280
+ git: {
281
+ ...(remote ? { remote } : {}),
282
+ sha,
283
+ ...(dirty ? { dirty } : {}),
284
+ },
285
+ };
286
+ } catch {
287
+ return undefined;
288
+ }
289
+ }
290
+
291
+ /**
292
+ * stripCredentials drops what can carry a secret from a URL remote — its
293
+ * userinfo, query and fragment (`https://user:token@host/o/r` →
294
+ * `https://host/o/r`). An scp-like remote (`git@host:o/r.git`) names a user
295
+ * and no secret, and is kept. A URL that does not parse is dropped rather
296
+ * than guessed at.
297
+ */
298
+ export function stripCredentials(remote) {
299
+ if (!remote.includes('://')) return remote;
300
+ try {
301
+ const u = new URL(remote);
302
+ u.username = '';
303
+ u.password = '';
304
+ u.search = '';
305
+ u.hash = '';
306
+ return u.toString();
307
+ } catch {
308
+ return '';
309
+ }
310
+ }
311
+
252
312
  export async function readText(cwd, name) {
253
313
  try {
254
314
  return await readFile(join(cwd, name), 'utf8');
@@ -2,6 +2,7 @@
2
2
  "//": "The compiler options Juno type-checks with server-side. internal/verify's tsconfig_preset_test.go asserts this file and verify.CompilerOptions() are identical, so `tsc --noEmit` passing here means the deploy will not 422 on a type error. Extend it; do not fork it.",
3
3
  "compilerOptions": {
4
4
  "target": "ES2022",
5
+ "lib": ["ES2022", "DOM", "DOM.Iterable", "ESNext.Disposable"],
5
6
  "module": "CommonJS",
6
7
  "moduleResolution": "node",
7
8
  "strict": true,
package/verbs.json CHANGED
@@ -222,6 +222,20 @@
222
222
  { "command": "webhook write", "tool": "write_webhook", "id_field": "id" },
223
223
  { "command": "webhook fire", "tool": "fire_webhook", "id_field": "id" },
224
224
  { "command": "credential list", "tool": "list_credentials" },
225
+ { "command": "person list", "tool": "list_people" },
226
+ { "command": "person get", "tool": "get_person", "id_field": "id" },
227
+ { "command": "person write", "tool": "write_person", "id_field": "id" },
228
+ {
229
+ "command": "question list",
230
+ "tool": "list_questions",
231
+ "flags": { "closed": { "field": "closed", "type": "string" } }
232
+ },
233
+ {
234
+ "command": "question get",
235
+ "tool": "get_question",
236
+ "id_field": "run_id",
237
+ "flags": { "handle": { "field": "handle_id", "type": "string" } }
238
+ },
225
239
  {
226
240
  "command": "ha get-entity",
227
241
  "tool": "get_entity",